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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +64 -17
  4. data/ROADMAP.md +98 -43
  5. data/SECURITY.md +8 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +146 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
  12. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  13. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  14. data/app/jobs/add_auth/account_request_job.rb +17 -0
  15. data/app/mailers/add_auth/account_mailer.rb +12 -0
  16. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  17. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  18. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  19. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  20. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  21. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  22. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  23. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  24. data/app/views/add_auth/accounts/_request.html.erb +17 -0
  25. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  26. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  27. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  28. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  29. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  30. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  31. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  32. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  33. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  34. data/app/views/add_auth/sessions/index.html.erb +7 -0
  35. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  36. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  37. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  38. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  39. data/exe/add_auth-preflight +26 -0
  40. data/lib/add_auth/configuration.rb +78 -4
  41. data/lib/add_auth/core/access_policy.rb +11 -1
  42. data/lib/add_auth/core/account_lifecycle.rb +295 -0
  43. data/lib/add_auth/core/account_policy.rb +68 -0
  44. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  45. data/lib/add_auth/core/external_identities.rb +278 -0
  46. data/lib/add_auth/core/intake.rb +6 -1
  47. data/lib/add_auth/core/maintenance.rb +5 -3
  48. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  49. data/lib/add_auth/core/migration/readiness.rb +41 -0
  50. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  51. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  52. data/lib/add_auth/core/mobile_profile.rb +46 -0
  53. data/lib/add_auth/core/mobile_response.rb +12 -0
  54. data/lib/add_auth/core/native_authentication.rb +77 -0
  55. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  56. data/lib/add_auth/core/passwords/credential.rb +49 -0
  57. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  58. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  59. data/lib/add_auth/core/remaining_factors.rb +24 -0
  60. data/lib/add_auth/core/sessions.rb +102 -17
  61. data/lib/add_auth/core/step_up.rb +13 -1
  62. data/lib/add_auth/migration/static_inventory.rb +116 -0
  63. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  64. data/lib/add_auth/rails/authentication.rb +1 -1
  65. data/lib/add_auth/rails/authentication_pages.rb +6 -1
  66. data/lib/add_auth/rails/doctor.rb +80 -6
  67. data/lib/add_auth/rails/ejection.rb +1 -1
  68. data/lib/add_auth/rails/engine.rb +6 -1
  69. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  70. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  71. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  72. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  73. data/lib/add_auth/rails/password_adoption.rb +37 -0
  74. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  77. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  78. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  80. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  81. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  82. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  83. data/lib/add_auth/rails/runtime.rb +179 -8
  84. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  85. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  86. data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
  87. data/lib/add_auth/rails/stores/authority.rb +28 -0
  88. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  89. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  90. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  91. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  92. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  93. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  94. data/lib/add_auth/result.rb +1 -0
  95. data/lib/add_auth/version.rb +1 -1
  96. data/lib/add_auth.rb +4 -0
  97. data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
  98. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  99. data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  101. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  102. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  103. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  104. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  105. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  106. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  107. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  110. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  111. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  112. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  113. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  114. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  115. data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
  116. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  117. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  119. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  120. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  121. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  122. data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
  123. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  124. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
  125. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  126. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
  127. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  128. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  129. data/lib/tasks/add_auth.rake +18 -1
  130. 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: config.eligible, lifetime: config.session.lifetime,
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: config.trusted_recovery_address, password_enabled: config.passwords_enabled)
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: config.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
- {sign_out_everywhere: {methods: [:password, :email_link, :passkey], return_to: "/sessions/revoke-all", label: "sign out everywhere"}}.merge(defaults).merge(config.step_up.purposes)
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: config.eligible, rp_id: config.passkeys.rp_id, origins: config.passkeys.origins, name: config.passkeys.name,
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
- path = {"sign_in" => "/sign-in/link", "reauthentication" => "/reauthenticate/link", "recovery" => "/recover/link"}.fetch(purpose.to_s)
214
- "#{uri.to_s.delete_suffix("/")}#{path}?token=#{URI.encode_www_form_component(token)}"
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