add_auth 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +64 -17
- data/ROADMAP.md +98 -43
- data/SECURITY.md +8 -0
- data/app/controllers/add_auth/accounts_controller.rb +146 -0
- data/app/controllers/add_auth/assets_controller.rb +2 -1
- data/app/controllers/add_auth/external_identities_controller.rb +56 -0
- data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
- data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
- data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
- data/app/jobs/add_auth/account_delivery_job.rb +14 -0
- data/app/jobs/add_auth/account_request_job.rb +17 -0
- data/app/mailers/add_auth/account_mailer.rb +12 -0
- data/app/views/add_auth/account_mailer/link.text.erb +7 -0
- data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
- data/app/views/add_auth/accounts/_delete.html.erb +6 -0
- data/app/views/add_auth/accounts/_email.html.erb +12 -0
- data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
- data/app/views/add_auth/accounts/_password.html.erb +12 -0
- data/app/views/add_auth/accounts/_proof.html.erb +16 -0
- data/app/views/add_auth/accounts/_register.html.erb +17 -0
- data/app/views/add_auth/accounts/_request.html.erb +17 -0
- data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
- data/app/views/add_auth/external_identities/_index.html.erb +22 -0
- data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
- data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
- data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
- data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
- data/app/views/add_auth/sessions/_list.html.erb +1 -1
- data/app/views/add_auth/sessions/index.html.erb +7 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
- data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
- data/app/views/layouts/add_auth/authentication.html.erb +4 -3
- data/exe/add_auth-preflight +26 -0
- data/lib/add_auth/configuration.rb +78 -4
- data/lib/add_auth/core/access_policy.rb +11 -1
- data/lib/add_auth/core/account_lifecycle.rb +295 -0
- data/lib/add_auth/core/account_policy.rb +68 -0
- data/lib/add_auth/core/external_identities/evidence.rb +98 -0
- data/lib/add_auth/core/external_identities.rb +278 -0
- data/lib/add_auth/core/intake.rb +6 -1
- data/lib/add_auth/core/maintenance.rb +5 -3
- data/lib/add_auth/core/migration/account_adoption.rb +79 -0
- data/lib/add_auth/core/migration/readiness.rb +41 -0
- data/lib/add_auth/core/mobile_authentication.rb +35 -0
- data/lib/add_auth/core/mobile_handoffs.rb +83 -0
- data/lib/add_auth/core/mobile_profile.rb +46 -0
- data/lib/add_auth/core/mobile_response.rb +12 -0
- data/lib/add_auth/core/native_authentication.rb +77 -0
- data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
- data/lib/add_auth/core/passwords/credential.rb +49 -0
- data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
- data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
- data/lib/add_auth/core/remaining_factors.rb +24 -0
- data/lib/add_auth/core/sessions.rb +102 -17
- data/lib/add_auth/core/step_up.rb +13 -1
- data/lib/add_auth/migration/static_inventory.rb +116 -0
- data/lib/add_auth/rails/account_password_entry.rb +24 -0
- data/lib/add_auth/rails/authentication.rb +1 -1
- data/lib/add_auth/rails/authentication_pages.rb +6 -1
- data/lib/add_auth/rails/doctor.rb +80 -6
- data/lib/add_auth/rails/ejection.rb +1 -1
- data/lib/add_auth/rails/engine.rb +6 -1
- data/lib/add_auth/rails/migration/account_store.rb +41 -0
- data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
- data/lib/add_auth/rails/migration/key_type.rb +23 -0
- data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
- data/lib/add_auth/rails/password_adoption.rb +37 -0
- data/lib/add_auth/rails/provider_enrollment.rb +61 -0
- data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
- data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
- data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
- data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
- data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
- data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
- data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
- data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
- data/lib/add_auth/rails/runtime.rb +179 -8
- data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
- data/lib/add_auth/rails/stores/account_lock.rb +16 -7
- data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
- data/lib/add_auth/rails/stores/authority.rb +28 -0
- data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
- data/lib/add_auth/rails/stores/external_identities.rb +91 -0
- data/lib/add_auth/rails/stores/maintenance.rb +1 -1
- data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
- data/lib/add_auth/rails/stores/security_events.rb +3 -6
- data/lib/add_auth/rails/user_lifecycle.rb +8 -10
- data/lib/add_auth/result.rb +1 -0
- data/lib/add_auth/version.rb +1 -1
- data/lib/add_auth.rb +4 -0
- data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
- data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
- data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
- data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
- data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
- data/lib/generators/add_auth/feature_configuration.rb +21 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
- data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
- data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
- data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
- data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
- data/lib/tasks/add_auth.rake +18 -1
- metadata +101 -2
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This adapter deliberately has no `require "omniauth"`: provider gems remain
|
|
4
|
+
# host-selected optional dependencies. It accepts only the in-process Rack
|
|
5
|
+
# callback product that OmniAuth installs after a strategy completes. It never
|
|
6
|
+
# accepts HTTP parameters or an auth hash supplied by a controller.
|
|
7
|
+
module AddAuth
|
|
8
|
+
module Rails
|
|
9
|
+
module ProviderLibraries
|
|
10
|
+
module OmniAuth
|
|
11
|
+
# An immutable, allowlisted projection of an OmniAuth callback. The raw
|
|
12
|
+
# auth hash can include OAuth credentials and profile data, so this
|
|
13
|
+
# object intentionally exposes neither. A configured Verifier maps only
|
|
14
|
+
# the identity claims it needs into Core's trusted verifier contract.
|
|
15
|
+
class CallbackResult
|
|
16
|
+
attr_reader :provider, :strategy_class
|
|
17
|
+
|
|
18
|
+
def self.capture(env:, provider:)
|
|
19
|
+
return unless env.respond_to?(:[])
|
|
20
|
+
|
|
21
|
+
strategy = env["omniauth.strategy"]
|
|
22
|
+
auth = env["omniauth.auth"]
|
|
23
|
+
return unless defined?(::OmniAuth::Strategy) && strategy.is_a?(::OmniAuth::Strategy)
|
|
24
|
+
return unless defined?(::OmniAuth::AuthHash) && auth.is_a?(::OmniAuth::AuthHash)
|
|
25
|
+
return unless strategy.name.to_s == provider.to_s && auth.provider.to_s == provider.to_s
|
|
26
|
+
|
|
27
|
+
new(provider: provider.to_s, strategy_class: strategy.class.name, auth: auth)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(provider:, strategy_class:, auth:)
|
|
31
|
+
@provider = provider.dup.freeze
|
|
32
|
+
@strategy_class = strategy_class.dup.freeze
|
|
33
|
+
@auth = auth
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
private_class_method :new
|
|
37
|
+
|
|
38
|
+
# A mapping is declared in trusted server configuration, rather than
|
|
39
|
+
# selected by a request. Its paths are evaluated against the Rack
|
|
40
|
+
# result before this object crosses into Core.
|
|
41
|
+
def values(mapping)
|
|
42
|
+
mapping.transform_values { |path| read(path) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect = "#<AddAuth::Rails::ProviderLibraries::OmniAuth::CallbackResult [FILTERED]>"
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def read(path)
|
|
50
|
+
Array(path).reduce(@auth) do |value, key|
|
|
51
|
+
if value.respond_to?(:[])
|
|
52
|
+
value[key] || value[key.to_s] || value[key.to_sym]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
rescue NoMethodError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds the callable supplied to
|
|
61
|
+
# Core::ExternalIdentities::Configuration#verifier. It binds that
|
|
62
|
+
# configuration to one OmniAuth strategy and one static claim map. The
|
|
63
|
+
# provider strategy remains responsible for authorization-code exchange,
|
|
64
|
+
# JWKS/signature validation, state and nonce verification; this adapter
|
|
65
|
+
# only prevents an HTTP parameter/raw profile from impersonating that
|
|
66
|
+
# completed strategy result.
|
|
67
|
+
class Verifier
|
|
68
|
+
DEFAULT_MAPPING = {
|
|
69
|
+
issuer: %i[extra id_info iss],
|
|
70
|
+
audience: %i[extra id_info aud],
|
|
71
|
+
subject: %i[extra id_info sub],
|
|
72
|
+
authenticated_at: %i[extra id_info auth_time]
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
def initialize(provider:, provenance:, mapping: DEFAULT_MAPPING)
|
|
76
|
+
@provider = text(provider.to_s)
|
|
77
|
+
@provenance = text(provenance)
|
|
78
|
+
@mapping = normalize_mapping(mapping)
|
|
79
|
+
freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def call(server_result:, transaction:)
|
|
83
|
+
return unless server_result.is_a?(CallbackResult) && server_result.provider == @provider
|
|
84
|
+
return unless transaction.respond_to?(:id)
|
|
85
|
+
|
|
86
|
+
claims = server_result.values(@mapping)
|
|
87
|
+
issuer, audience, subject = claims.values_at(:issuer, :audience, :subject)
|
|
88
|
+
return unless [issuer, audience, subject].all? { |value| valid_text?(value) }
|
|
89
|
+
return unless subject == server_result.values(subject: :uid).fetch(:subject)
|
|
90
|
+
|
|
91
|
+
authenticated_at = time(claims[:authenticated_at])
|
|
92
|
+
return if claims[:authenticated_at] && !authenticated_at
|
|
93
|
+
|
|
94
|
+
{issuer: issuer, audience: audience, subject: subject, provenance: @provenance,
|
|
95
|
+
authenticated_at: authenticated_at}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def normalize_mapping(mapping)
|
|
101
|
+
required = %i[issuer audience subject]
|
|
102
|
+
raise ArgumentError, "provider claim mapping must contain issuer, audience and subject" unless mapping.is_a?(Hash) &&
|
|
103
|
+
(required - mapping.keys.map(&:to_sym)).empty?
|
|
104
|
+
|
|
105
|
+
mapping.to_h.transform_keys(&:to_sym).transform_values do |path|
|
|
106
|
+
parts = Array(path)
|
|
107
|
+
raise ArgumentError, "provider claim paths must be nonempty" if parts.empty? || parts.any? { |part| !part.is_a?(String) && !part.is_a?(Symbol) }
|
|
108
|
+
|
|
109
|
+
parts.map(&:to_sym).freeze
|
|
110
|
+
end.freeze
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def text(value)
|
|
114
|
+
raise ArgumentError, "invalid provider verifier value" unless valid_text?(value)
|
|
115
|
+
|
|
116
|
+
value.dup.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def valid_text?(value)
|
|
120
|
+
value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def time(value)
|
|
124
|
+
return value if value.is_a?(Time)
|
|
125
|
+
return unless value.is_a?(Integer) || value.is_a?(Float)
|
|
126
|
+
return if value.is_a?(Float) && !value.finite?
|
|
127
|
+
|
|
128
|
+
Time.at(value).utc
|
|
129
|
+
rescue ArgumentError, RangeError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module ProviderLibraries
|
|
8
|
+
# Correlates the Core one-use transaction with OmniAuth's already-CSRF-
|
|
9
|
+
# protected state value. It stores only opaque Core data in the ordinary
|
|
10
|
+
# application session; OAuth tokens, profile data, and claims never enter
|
|
11
|
+
# this boundary. Apple form_post needs its own cookie transport because
|
|
12
|
+
# its cross-site callback deliberately lacks this session.
|
|
13
|
+
class OmniAuthCorrelation
|
|
14
|
+
PENDING_KEY = "add_auth.provider_pending"
|
|
15
|
+
CORRELATIONS_KEY = "add_auth.provider_correlations"
|
|
16
|
+
ENV_KEY = "add_auth.provider_correlation"
|
|
17
|
+
|
|
18
|
+
def self.remember!(session:, provider:, transaction:, browser_secret:, configuration_id:, purpose:, remember: false)
|
|
19
|
+
payload = {"provider" => provider.to_s, "transaction" => transaction, "browser_secret" => browser_secret,
|
|
20
|
+
"configuration_id" => configuration_id, "purpose" => purpose.to_s, "remember" => remember == true}
|
|
21
|
+
raise ArgumentError, "provider transaction correlation is invalid" unless valid_payload?(payload)
|
|
22
|
+
|
|
23
|
+
session[PENDING_KEY] ||= {}
|
|
24
|
+
raise ArgumentError, "provider transaction correlation is invalid" unless session[PENDING_KEY].is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
session[PENDING_KEY][payload.fetch("provider")] = payload
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.valid_payload?(payload)
|
|
30
|
+
payload.is_a?(Hash) && %w[provider transaction browser_secret configuration_id purpose].all? { |key| text?(payload[key]) } &&
|
|
31
|
+
[true, false].include?(payload["remember"])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.text?(value)
|
|
35
|
+
value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
|
|
36
|
+
end
|
|
37
|
+
private_class_method :text?, :valid_payload?
|
|
38
|
+
|
|
39
|
+
def initialize(app, providers:, lifetime: 600, clock: -> { Time.now })
|
|
40
|
+
raise ArgumentError, "provider correlation lifetime is invalid" unless lifetime.is_a?(Integer) && lifetime.between?(1, 600)
|
|
41
|
+
raise ArgumentError, "provider correlation providers are invalid" unless providers.is_a?(Array) && providers.all? { |name| self.class.send(:text?, name.to_s) }
|
|
42
|
+
|
|
43
|
+
@app, @providers, @lifetime, @clock = app, providers.map(&:to_s).freeze, lifetime, clock
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(env)
|
|
47
|
+
request = Rack::Request.new(env)
|
|
48
|
+
provider = provider_for(request.path_info)
|
|
49
|
+
return @app.call(env) unless provider
|
|
50
|
+
|
|
51
|
+
return request.post? ? start(env, provider) : @app.call(env) if request.path_info == "/auth/#{provider}"
|
|
52
|
+
callback(env, request, provider)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def provider_for(path)
|
|
58
|
+
@providers.find { |provider| ["/auth/#{provider}", "/auth/#{provider}/callback"].include?(path) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def start(env, provider)
|
|
62
|
+
session = env.fetch("rack.session", {})
|
|
63
|
+
pending = session.fetch(PENDING_KEY, {}).delete(provider)
|
|
64
|
+
return @app.call(env) if pending.nil?
|
|
65
|
+
return failure unless self.class.send(:valid_payload?, pending) && pending.fetch("provider") == provider
|
|
66
|
+
|
|
67
|
+
status, headers, body = @app.call(env)
|
|
68
|
+
state = session["omniauth.state"]
|
|
69
|
+
return [status, headers, body] unless status.between?(300, 399) && self.class.send(:text?, state)
|
|
70
|
+
|
|
71
|
+
correlations = session[CORRELATIONS_KEY] ||= {}
|
|
72
|
+
return failure unless correlations.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
purge!(correlations)
|
|
75
|
+
correlations[state] = pending.merge("expires_at" => (@clock.call + @lifetime).to_i)
|
|
76
|
+
[status, headers, body]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def callback(env, request, provider)
|
|
80
|
+
state = request.params["state"]
|
|
81
|
+
session = env.fetch("rack.session", {})
|
|
82
|
+
correlations = session.fetch(CORRELATIONS_KEY, {})
|
|
83
|
+
payload = correlations.delete(state) if self.class.send(:text?, state) && correlations.is_a?(Hash)
|
|
84
|
+
# A shared OmniAuth stack may serve other host-owned OAuth journeys.
|
|
85
|
+
# Only attach authority correlation for a transaction we started. The
|
|
86
|
+
# AddAuth callback controller independently rejects missing correlation.
|
|
87
|
+
return @app.call(env) if payload.nil?
|
|
88
|
+
return failure unless self.class.send(:valid_payload?, payload) && payload.fetch("provider") == provider &&
|
|
89
|
+
payload.fetch("expires_at", 0).is_a?(Integer) && payload.fetch("expires_at") > @clock.call.to_i
|
|
90
|
+
|
|
91
|
+
env[ENV_KEY] = payload.slice("transaction", "browser_secret", "configuration_id", "purpose", "remember")
|
|
92
|
+
@app.call(env)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def purge!(correlations)
|
|
96
|
+
now = @clock.call.to_i
|
|
97
|
+
correlations.delete_if { |_state, payload| !payload.is_a?(Hash) || payload["expires_at"].to_i <= now }
|
|
98
|
+
correlations.shift while correlations.size >= 8
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def failure
|
|
102
|
+
[422, {"content-type" => "text/plain", "cache-control" => "no-store"}, ["Provider sign-in could not be verified."]]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Optional bridge for a host that uses openid_connect directly rather than
|
|
4
|
+
# OmniAuth. It deliberately accepts an OpenIDConnect::AccessToken obtained by
|
|
5
|
+
# the server-side authorization-code exchange, never `params[:id_token]`.
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
module ProviderLibraries
|
|
9
|
+
module OpenIdConnect
|
|
10
|
+
class CallbackResult
|
|
11
|
+
attr_reader :issuer, :audience, :subject, :authenticated_at, :provenance
|
|
12
|
+
|
|
13
|
+
def self.capture(access_token:, verification_key:, issuer:, audience:, nonce:, provenance:)
|
|
14
|
+
return unless defined?(::OpenIDConnect::AccessToken) && access_token.is_a?(::OpenIDConnect::AccessToken)
|
|
15
|
+
return unless access_token.id_token.is_a?(String) && !access_token.id_token.empty?
|
|
16
|
+
|
|
17
|
+
# `decode` performs JWS verification using the discovery/JWKS key
|
|
18
|
+
# (or an equivalent pinned verification key); `verify!` performs
|
|
19
|
+
# the OIDC issuer, audience, expiration and nonce checks. AddAuth
|
|
20
|
+
# deliberately delegates both operations to openid_connect.
|
|
21
|
+
token = ::OpenIDConnect::ResponseObject::IdToken.decode(access_token.id_token, verification_key)
|
|
22
|
+
token.verify!(issuer: issuer, audience: audience, nonce: nonce)
|
|
23
|
+
new(issuer: token.iss, audience: audience_for(token.aud, audience), subject: token.sub,
|
|
24
|
+
authenticated_at: token.auth_time && Time.at(token.auth_time).utc, provenance: provenance)
|
|
25
|
+
rescue
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(issuer:, audience:, subject:, authenticated_at:, provenance:)
|
|
30
|
+
@issuer = text(issuer)
|
|
31
|
+
@audience = text(audience)
|
|
32
|
+
@subject = text(subject)
|
|
33
|
+
@authenticated_at = authenticated_at&.dup&.freeze
|
|
34
|
+
@provenance = text(provenance)
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
private_class_method :new
|
|
38
|
+
|
|
39
|
+
def to_verified_claims(transaction:)
|
|
40
|
+
return unless transaction.respond_to?(:id)
|
|
41
|
+
|
|
42
|
+
{issuer: issuer, audience: audience, subject: subject, authenticated_at: authenticated_at, provenance: provenance}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect = "#<AddAuth::Rails::ProviderLibraries::OpenIdConnect::CallbackResult [FILTERED]>"
|
|
46
|
+
|
|
47
|
+
def self.audience_for(value, expected)
|
|
48
|
+
Array(value).find { |candidate| candidate == expected }
|
|
49
|
+
end
|
|
50
|
+
private_class_method :audience_for
|
|
51
|
+
|
|
52
|
+
def text(value)
|
|
53
|
+
raise ArgumentError, "invalid verified OIDC claim" unless value.is_a?(String) && value.valid_encoding? &&
|
|
54
|
+
value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
|
|
55
|
+
|
|
56
|
+
value.dup.freeze
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module ProviderLibraries
|
|
6
|
+
# Mint the form token with the actual maintained OmniAuth validator. A
|
|
7
|
+
# Rails adopter may already configure its session key; using a separate
|
|
8
|
+
# default Rack validator would generate a token that cannot be accepted.
|
|
9
|
+
module RequestProtection
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validator
|
|
13
|
+
return unless defined?(::OmniAuth::AuthenticityTokenProtection)
|
|
14
|
+
require "rack/protection"
|
|
15
|
+
phase = ::OmniAuth.config.request_validation_phase
|
|
16
|
+
candidate = (phase.is_a?(Class) && phase <= ::Rack::Protection::AuthenticityToken) ? phase.new : phase
|
|
17
|
+
candidate if candidate.is_a?(::Rack::Protection::AuthenticityToken) || rails_validator?(candidate)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def token(session:, rails_token: nil)
|
|
21
|
+
configured = validator
|
|
22
|
+
raise AddAuth::Error, "configure a supported OmniAuth request validator" unless configured
|
|
23
|
+
if rails_validator?(configured)
|
|
24
|
+
raise AddAuth::Error, "Rails request token is unavailable" unless rails_token.respond_to?(:call)
|
|
25
|
+
return rails_token.call
|
|
26
|
+
end
|
|
27
|
+
configured.mask_authenticity_token(session)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parameter
|
|
31
|
+
configured = validator
|
|
32
|
+
raise AddAuth::Error, "configure a supported OmniAuth request validator" unless configured
|
|
33
|
+
rails_validator?(configured) ? ::ActionController::Base.request_forgery_protection_token : configured.options.fetch(:authenticity_param)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rails_validator?(candidate)
|
|
37
|
+
defined?(::OmniAuth::RailsCsrfProtection::TokenVerifier) && candidate.is_a?(::OmniAuth::RailsCsrfProtection::TokenVerifier)
|
|
38
|
+
end
|
|
39
|
+
private_class_method :rails_validator?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -14,10 +14,156 @@ module AddAuth
|
|
|
14
14
|
|
|
15
15
|
def config = AddAuth.configuration
|
|
16
16
|
|
|
17
|
+
def account_policy
|
|
18
|
+
Core::AccountPolicy.new(enabled: config.lifecycle.enabled,
|
|
19
|
+
eligible: ->(user) { config.eligible.call(user) == true && config.lifecycle.eligible.call(user) == true },
|
|
20
|
+
confirmation_required: config.lifecycle.confirmation_required, reset_unconfirmed: config.lifecycle.reset_unconfirmed)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def eligible(user)
|
|
24
|
+
account_policy.allowed?(user)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def trusted_recovery_address(user)
|
|
28
|
+
config.trusted_recovery_address.call(user) if account_policy.trusted_address?(user)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def authority
|
|
32
|
+
require "add_auth/rails/stores/authority"
|
|
33
|
+
tokens = []
|
|
34
|
+
tokens << ::AddAuthSignInToken if defined?(::AddAuthSignInToken) && ::AddAuthSignInToken.table_exists?
|
|
35
|
+
tokens << ::AddAuthAccountToken if defined?(::AddAuthAccountToken) && ::AddAuthAccountToken.table_exists?
|
|
36
|
+
ceremonies = []
|
|
37
|
+
ceremonies << ::AddAuthCeremony if defined?(::AddAuthCeremony) && ::AddAuthCeremony.table_exists?
|
|
38
|
+
ceremonies << ::AddAuthExternalTransaction if defined?(::AddAuthExternalTransaction) && ::AddAuthExternalTransaction.table_exists?
|
|
39
|
+
ceremonies << ::AddAuthMobileHandoff if defined?(::AddAuthMobileHandoff) && ::AddAuthMobileHandoff.table_exists?
|
|
40
|
+
invalidator = if defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity.table_exists?
|
|
41
|
+
->(user_id:, at:) { Core::ExternalIdentities.invalidate_credentials_in_transaction(store: external_identity_store, user_id: user_id, at: at) }
|
|
42
|
+
end
|
|
43
|
+
Stores::Authority.new(user_model: ::User, session_model: ::Session, token_models: tokens, ceremony_models: ceremonies, external_invalidator: invalidator)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def external_identity_store
|
|
47
|
+
require "add_auth/rails/stores/external_identities"
|
|
48
|
+
Stores::ExternalIdentities.new(user_model: ::User, identity_model: ::AddAuthExternalIdentity, transaction_model: ::AddAuthExternalTransaction)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def external_provider(id)
|
|
52
|
+
config.external_identities.provider(id) if config.external_identities.enabled
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def external_reauthentication? = config.external_identities.enabled && config.external_identities.providers.any?(&:reauthentication?)
|
|
56
|
+
|
|
57
|
+
def external_identities
|
|
58
|
+
options = config.external_identities
|
|
59
|
+
raise AddAuth::Error, "enable external identities and register a provider first" unless options.enabled && options.configurations.any?
|
|
60
|
+
Core::ExternalIdentities.new(store: external_identity_store, configurations: options.configurations,
|
|
61
|
+
sessions: sessions, access_policy: access_policy, policy: step_up_policy, eligible: method(:eligible),
|
|
62
|
+
digest: config.sign_in_token_digest, revoke_authority: authority.method(:revoke),
|
|
63
|
+
remaining_method: remaining_factors,
|
|
64
|
+
enrollment_eligible: ->(user) { account_policy.allowed?(user, purpose: :confirm) }, enabled: true, mobile_enabled: config.mobile.enabled)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def remaining_factors
|
|
68
|
+
require "add_auth/core/remaining_factors"
|
|
69
|
+
require "add_auth/core/passwords/credential"
|
|
70
|
+
credential = Core::Passwords::Credential.new(legacy_verifier: config.legacy_password_verifier)
|
|
71
|
+
Core::RemainingFactors.new(access_policy: access_policy,
|
|
72
|
+
passkey_count: ->(user) { (defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?) ? ::AddAuthCredential.where(user_id: user.id, revoked_at: nil).count : 0 },
|
|
73
|
+
password_available: ->(user) { credential.available?(user: user, current: config.current_password_support) })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def provider_enrollment
|
|
77
|
+
require "add_auth/rails/provider_enrollment"
|
|
78
|
+
ProviderEnrollment.new(model: ::AddAuthExternalTransaction, digest: config.sign_in_token_digest, key: key("external-enrollment"))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def accounts
|
|
82
|
+
raise AddAuth::Error, "enable account lifecycle first" unless config.lifecycle.enabled
|
|
83
|
+
if !account_policy.confirmation_required? && !::User.column_names.include?("add_auth_provisioned_at")
|
|
84
|
+
raise AddAuth::Error, "rerun add_auth:accounts and migrate before enabling optional confirmation"
|
|
85
|
+
end
|
|
86
|
+
require "add_auth/rails/stores/account_tokens"
|
|
87
|
+
Core::AccountLifecycle.new(store: Stores::AccountTokens.new(user_model: ::User, token_model: ::AddAuthAccountToken,
|
|
88
|
+
session_model: ::Session, address_model: ::AddAuthAddressClaim, authority: authority, provision: config.lifecycle.provision, delete_account: config.lifecycle.delete_account),
|
|
89
|
+
digest: config.sign_in_token_digest, delivery_cipher: DeliveryCipher.new(key: key("account-proofs")),
|
|
90
|
+
policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: method(:trusted_recovery_address),
|
|
91
|
+
lifetime: config.lifecycle.proof_lifetime, notify: ->(**event) { security_events.issue(**event) if config.notifications.enabled },
|
|
92
|
+
sessions: sessions, step_up_policy: step_up_policy, profile_attributes: config.lifecycle.profile_attributes, deletion_allowed: config.lifecycle.deletion_allowed,
|
|
93
|
+
external_identities: -> { external_identities })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def enqueue_account_request(identifier:, purpose:)
|
|
97
|
+
job = ::AddAuth::AccountRequestJob.perform_later(encrypt_intake(identifier: identifier, purpose: purpose))
|
|
98
|
+
raise AddAuth::Error, "account request queue unavailable" unless job
|
|
99
|
+
job
|
|
100
|
+
rescue
|
|
101
|
+
raise AddAuth::Error, "account request queue unavailable", cause: nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def account_proof_url(token, purpose:)
|
|
105
|
+
raise AddAuth::Error, "unsupported account proof" unless Core::AccountLifecycle::PURPOSES.include?(purpose)
|
|
106
|
+
authentication_url("/account/proofs/#{purpose}", token: token)
|
|
107
|
+
end
|
|
108
|
+
|
|
17
109
|
def sessions
|
|
18
110
|
Core::Sessions.new(store: Stores::Sessions.new(user_model: ::User, session_model: ::Session),
|
|
19
|
-
digest: config.session_token_digest, eligible:
|
|
20
|
-
idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy
|
|
111
|
+
digest: config.session_token_digest, eligible: method(:eligible), lifetime: config.session.lifetime,
|
|
112
|
+
idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy,
|
|
113
|
+
password_lifecycle: password_lifecycle,
|
|
114
|
+
mobile_profile: mobile_profile,
|
|
115
|
+
verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.denial(user) : :disabled },
|
|
116
|
+
remembered_profile: config.lifecycle.enabled ? {lifetime: config.lifecycle.remember_lifetime, idle_timeout: config.lifecycle.remember_idle_timeout} : nil,
|
|
117
|
+
on_sign_in: method(:record_sign_in))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def mobile_profile
|
|
121
|
+
options = config.mobile
|
|
122
|
+
return unless options.enabled
|
|
123
|
+
raise AddAuth::Error, "enable session_upgrade before mobile sessions" unless config.session.enabled
|
|
124
|
+
Core::MobileProfile.new(lifetime: options.lifetime, idle_timeout: options.idle_timeout, clients: options.clients, callbacks: options.callbacks)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def mobile_authentication
|
|
128
|
+
require "add_auth/core/mobile_authentication"
|
|
129
|
+
Core::MobileAuthentication.new(sessions: sessions, profile: mobile_profile,
|
|
130
|
+
intake: intake, verify_password: method(:authenticate_password))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def mobile_handoffs
|
|
134
|
+
require "add_auth/core/mobile_handoffs"
|
|
135
|
+
require "add_auth/rails/stores/mobile_handoffs"
|
|
136
|
+
Core::MobileHandoffs.new(store: Stores::MobileHandoffs.new(user_model: ::User, handoff_model: ::AddAuthMobileHandoff, session_model: ::Session),
|
|
137
|
+
profile: mobile_profile, sessions: sessions, access_policy: access_policy, digest: config.sign_in_token_digest)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def native_apple
|
|
141
|
+
require "add_auth/core/native_authentication"
|
|
142
|
+
require "add_auth/rails/provider_libraries/apple_native"
|
|
143
|
+
require "add_auth/rails/provider_libraries/apple_jwks"
|
|
144
|
+
raise AddAuth::Error, "install and require ruby-jwt for native Apple authentication" unless defined?(::JWT)
|
|
145
|
+
raise AddAuth::Error, "configure native Apple providers as an explicit client map" unless config.mobile.apple_providers.is_a?(Hash)
|
|
146
|
+
providers = config.mobile.apple_providers.transform_values { |id| config.external_identities.native_provider(id) }
|
|
147
|
+
Core::NativeAuthentication.new(external_identities: external_identities, profile: mobile_profile,
|
|
148
|
+
providers: providers, intake: intake, accounts: (method(:accounts) if config.lifecycle.enabled), verify: ->(**arguments) {
|
|
149
|
+
ProviderLibraries::AppleNative::CallbackResult.capture(**arguments, jwks: ProviderLibraries::AppleJwks.new(cache: ::Rails.cache))
|
|
150
|
+
})
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def record_sign_in(user:, session:, at:)
|
|
154
|
+
require "add_auth/rails/stores/commit_dispatch"
|
|
155
|
+
Stores::CommitDispatch.event(transaction: ::User.current_transaction, event: "session_created.add_auth",
|
|
156
|
+
payload: {user_id: user.id, session_id: session.id, method: session.authenticated_with, occurred_at: at})
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def password_lifecycle
|
|
160
|
+
return unless config.lifecycle.enabled || ::User.column_names.include?("add_auth_password_scheme")
|
|
161
|
+
require "add_auth/core/passwords/lifecycle"
|
|
162
|
+
require "add_auth/rails/stores/account_credentials"
|
|
163
|
+
Core::Passwords::Lifecycle.new(store: Stores::AccountCredentials.new(user_model: ::User, authority: authority),
|
|
164
|
+
policy: method(:eligible), enabled: config.lifecycle.enabled,
|
|
165
|
+
maximum_attempts: config.lifecycle.maximum_attempts, unlock_in: config.lifecycle.unlock_in,
|
|
166
|
+
issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) })
|
|
21
167
|
end
|
|
22
168
|
|
|
23
169
|
def security_events
|
|
@@ -31,7 +177,9 @@ module AddAuth
|
|
|
31
177
|
Core::AccessPolicy.new(credentials: ->(id) {
|
|
32
178
|
::AddAuthCredential.find_by(external_id: id) if defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?
|
|
33
179
|
}, passkeys_enabled: config.passkeys.enabled, email_enabled: Core::Intake.email_available?(config),
|
|
34
|
-
trusted_recovery_address:
|
|
180
|
+
trusted_recovery_address: method(:trusted_recovery_address), password_enabled: config.passwords_enabled,
|
|
181
|
+
external_enabled: config.external_identities.enabled,
|
|
182
|
+
external_current: ->(**arguments) { external_identities.credential_current?(**arguments) })
|
|
35
183
|
end
|
|
36
184
|
|
|
37
185
|
def email(purpose: :sign_in)
|
|
@@ -41,7 +189,7 @@ module AddAuth
|
|
|
41
189
|
raise AddAuth::Error, "enable session_upgrade and email_link first" unless Core::Intake.email_available?(config)
|
|
42
190
|
Core::Strategies::EmailLink.new(store: Stores::EmailTokens.new(user_model: ::User,
|
|
43
191
|
token_model: ::AddAuthSignInToken, session_model: ::Session), digest: config.sign_in_token_digest,
|
|
44
|
-
delivery_cipher: DeliveryCipher.new(key: key("delivery")), eligible:
|
|
192
|
+
delivery_cipher: DeliveryCipher.new(key: key("delivery")), eligible: method(:eligible),
|
|
45
193
|
normalize_identifier: method(:normalize), identifier_for: ->(user) { user.email_address },
|
|
46
194
|
token_lifetime: {"reauthentication" => 300, "recovery" => 1200}.fetch(purpose.to_s, config.email_link.token_lifetime),
|
|
47
195
|
same_browser: (purpose.to_s == "recovery") ? false : config.email_link.same_browser, purpose: purpose,
|
|
@@ -82,7 +230,23 @@ module AddAuth
|
|
|
82
230
|
else
|
|
83
231
|
{}
|
|
84
232
|
end
|
|
85
|
-
|
|
233
|
+
if config.lifecycle.enabled
|
|
234
|
+
defaults = defaults.merge(
|
|
235
|
+
change_email: {methods: [:password, :email_link, :passkey], return_to: "/account/email", label: "change your email address"},
|
|
236
|
+
change_password: {methods: [:password, :email_link, :passkey], return_to: "/account/password", label: "change your password"},
|
|
237
|
+
delete_account: {methods: [:password, :email_link, :passkey], return_to: "/account/delete", label: "delete your account"}
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
if config.external_identities.enabled
|
|
241
|
+
defaults = defaults.merge(
|
|
242
|
+
link_external_identity: {methods: [:password, :email_link, :passkey], return_to: "/account/external-identities", label: "link a sign-in provider"},
|
|
243
|
+
unlink_external_identity: {methods: [:password, :email_link, :passkey], return_to: "/account/external-identities", label: "remove a sign-in provider"}
|
|
244
|
+
)
|
|
245
|
+
defaults = defaults.transform_values do |rule|
|
|
246
|
+
(!external_reauthentication? || rule[:require_passkey] || rule[:reauthentication] == false) ? rule : rule.merge(methods: (rule[:methods] + [:external_identity]).uniq)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
{sign_out_everywhere: {methods: [:password, :email_link, :passkey, *(:external_identity if external_reauthentication?)], return_to: "/sessions/revoke-all", label: "sign out everywhere"}}.merge(defaults).merge(config.step_up.purposes)
|
|
86
250
|
end
|
|
87
251
|
|
|
88
252
|
def passkeys
|
|
@@ -91,7 +255,7 @@ module AddAuth
|
|
|
91
255
|
Core::Strategies::Passkey.new(store: Stores::Passkeys.new(user_model: ::User, session_model: ::Session,
|
|
92
256
|
credential_model: ::AddAuthCredential, ceremony_model: ::AddAuthCeremony, token_model: ::AddAuthSignInToken), sessions: sessions,
|
|
93
257
|
policy: step_up_policy, access_policy: access_policy, digest: config.sign_in_token_digest,
|
|
94
|
-
eligible:
|
|
258
|
+
eligible: method(:eligible), rp_id: config.passkeys.rp_id, origins: config.passkeys.origins, name: config.passkeys.name,
|
|
95
259
|
limiter: method(:limit), anonymous_limit: config.passkeys.anonymous_limit,
|
|
96
260
|
notify: ->(**event) { security_events.issue(**event) }, allow_localhost: !::Rails.env.production?, support_url: config.support_url,
|
|
97
261
|
on_failure: ->(reason) { ::ActiveSupport::Notifications.instrument("passkey_failure.add_auth", reason: reason) })
|
|
@@ -103,6 +267,7 @@ module AddAuth
|
|
|
103
267
|
password_version: ->(user) { config.sign_in_token_digest.digest("password:#{user.password_digest}") },
|
|
104
268
|
credential_current: ->(user:, evidence:) {
|
|
105
269
|
evidence.method == :password ||
|
|
270
|
+
(evidence.method == :external_identity && access_policy.external_credential_current?(user: user, id: evidence.credential_id, version: evidence.credential_version)) ||
|
|
106
271
|
(evidence.method == :passkey && access_policy.credential_current?(user: user, id: evidence.credential_id) &&
|
|
107
272
|
::AddAuthCredential.find_by(external_id: evidence.credential_id)&.public_key == evidence.credential_version) ||
|
|
108
273
|
(evidence.method == :email_link && Core::Intake.email_available?(config) &&
|
|
@@ -204,14 +369,20 @@ module AddAuth
|
|
|
204
369
|
end
|
|
205
370
|
|
|
206
371
|
def sign_in_url(token, purpose: "sign_in")
|
|
372
|
+
path = {"sign_in" => "/sign-in/link", "reauthentication" => "/reauthenticate/link", "recovery" => "/recover/link"}.fetch(purpose.to_s)
|
|
373
|
+
authentication_url(path, token: token)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def authentication_url(path, **query)
|
|
207
377
|
uri = URI.parse(config.base_url.to_s)
|
|
208
378
|
valid = uri.host && !uri.userinfo && !uri.query && !uri.fragment && ["", "/"].include?(uri.path) &&
|
|
209
379
|
(uri.scheme == "https" || (!::Rails.env.production? && uri.scheme == "http"))
|
|
210
380
|
unless valid
|
|
211
381
|
raise AddAuth::Error, "configure base_url as a fixed HTTPS origin (HTTP allowed only outside production)"
|
|
212
382
|
end
|
|
213
|
-
|
|
214
|
-
|
|
383
|
+
"#{uri.to_s.delete_suffix("/")}#{path}?#{URI.encode_www_form(query)}"
|
|
384
|
+
rescue URI::InvalidURIError
|
|
385
|
+
raise AddAuth::Error, "configure a fixed HTTPS origin", cause: nil
|
|
215
386
|
end
|
|
216
387
|
end
|
|
217
388
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Stores
|
|
6
|
+
class AccountCredentials
|
|
7
|
+
def initialize(user_model:, authority:)
|
|
8
|
+
@users, @authority = user_model, authority
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def update_account(user:, **attributes)
|
|
12
|
+
raise AddAuth::Error, "credential changes require an account transaction" unless @users.connection.transaction_open?
|
|
13
|
+
user.update_columns(attributes)
|
|
14
|
+
user.reload
|
|
15
|
+
rescue ActiveRecord::RecordNotUnique
|
|
16
|
+
raise Core::AccountLifecycle::Conflict
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def replace_password(user:, password:)
|
|
20
|
+
raise AddAuth::Error, "credential changes require an account transaction" unless @users.connection.transaction_open?
|
|
21
|
+
user.password = password
|
|
22
|
+
unless user.valid?
|
|
23
|
+
user.reload
|
|
24
|
+
raise Core::AccountLifecycle::InvalidPassword
|
|
25
|
+
end
|
|
26
|
+
persist_password(user: user, digest: user.password_digest)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rehash_password(user:, password:)
|
|
30
|
+
fresh = @users.new(password: password)
|
|
31
|
+
persist_password(user: user, digest: fresh.password_digest)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def revoke_authority(user:, at:) = @authority.revoke(user_id: user.id, at: at)
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def persist_password(user:, digest:)
|
|
39
|
+
update_account(user: user, password_digest: digest, add_auth_password_scheme: "rails")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|