add_auth 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +57 -18
  4. data/ROADMAP.md +260 -242
  5. data/SECURITY.md +6 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +138 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  12. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  13. data/app/jobs/add_auth/account_request_job.rb +17 -0
  14. data/app/mailers/add_auth/account_mailer.rb +12 -0
  15. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  16. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  17. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  18. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  19. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  20. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  21. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  22. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  23. data/app/views/add_auth/accounts/_request.html.erb +14 -0
  24. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  25. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  26. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  27. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  28. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  29. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  30. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  31. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  32. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  33. data/app/views/add_auth/sessions/index.html.erb +7 -0
  34. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  35. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  36. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  37. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  38. data/exe/add_auth-preflight +26 -0
  39. data/lib/add_auth/configuration.rb +78 -4
  40. data/lib/add_auth/core/access_policy.rb +11 -1
  41. data/lib/add_auth/core/account_lifecycle.rb +251 -0
  42. data/lib/add_auth/core/account_policy.rb +50 -0
  43. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  44. data/lib/add_auth/core/external_identities.rb +278 -0
  45. data/lib/add_auth/core/intake.rb +6 -1
  46. data/lib/add_auth/core/maintenance.rb +5 -3
  47. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  48. data/lib/add_auth/core/migration/readiness.rb +41 -0
  49. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  50. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  51. data/lib/add_auth/core/mobile_profile.rb +46 -0
  52. data/lib/add_auth/core/mobile_response.rb +12 -0
  53. data/lib/add_auth/core/native_authentication.rb +77 -0
  54. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  55. data/lib/add_auth/core/passwords/credential.rb +49 -0
  56. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  57. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  58. data/lib/add_auth/core/remaining_factors.rb +24 -0
  59. data/lib/add_auth/core/sessions.rb +102 -17
  60. data/lib/add_auth/core/step_up.rb +13 -1
  61. data/lib/add_auth/migration/static_inventory.rb +116 -0
  62. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  63. data/lib/add_auth/rails/authentication.rb +1 -1
  64. data/lib/add_auth/rails/authentication_pages.rb +5 -0
  65. data/lib/add_auth/rails/doctor.rb +73 -3
  66. data/lib/add_auth/rails/ejection.rb +1 -1
  67. data/lib/add_auth/rails/engine.rb +6 -1
  68. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  69. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  70. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  71. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  72. data/lib/add_auth/rails/password_adoption.rb +37 -0
  73. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  74. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  77. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  78. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  80. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  81. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  82. data/lib/add_auth/rails/rate_limit_cache.rb +17 -0
  83. data/lib/add_auth/rails/runtime.rb +173 -9
  84. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  85. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  86. data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
  87. data/lib/add_auth/rails/stores/authority.rb +28 -0
  88. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  89. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  90. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  91. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  92. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  93. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  94. data/lib/add_auth/result.rb +1 -0
  95. data/lib/add_auth/version.rb +1 -1
  96. data/lib/add_auth.rb +4 -0
  97. data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
  98. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  99. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  101. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  102. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  103. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  104. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  105. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  106. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  107. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  110. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  111. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  112. data/lib/generators/add_auth/install/templates/initializer.rb +7 -1
  113. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  114. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  115. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  116. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  117. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  119. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  120. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  121. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  122. data/lib/tasks/add_auth.rake +18 -1
  123. metadata +86 -2
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class AccountsController < ::ApplicationController
5
+ include Rails::AuthenticationPages
6
+
7
+ skip_before_action :require_authentication, raise: false
8
+ layout "add_auth/authentication"
9
+ helper AddAuth::SignInsHelper
10
+ before_action :require_feature
11
+ before_action :private_response
12
+ before_action :require_add_auth_authentication, only: %i[edit_email change_email edit_password change_password confirm_deletion destroy]
13
+ protect_from_forgery with: :exception
14
+ rescue_from AddAuth::Error, "ActiveJob::EnqueueError", with: :service_unavailable
15
+
16
+ def new = page("register")
17
+ def check_email = page("check_email")
18
+
19
+ def create
20
+ admitted = intake(:register)
21
+ return intake_failure(admitted) if admitted.is_a?(Symbol)
22
+ result = Rails::Runtime.accounts.register(identifier: admitted, password: params[:password], profile: registration_profile)
23
+ if result.success?
24
+ redirect_to "/account/check-email", status: :see_other
25
+ else
26
+ @error = "Check your email and choose a password that meets this application's requirements."
27
+ page("register", status: :unprocessable_entity)
28
+ end
29
+ end
30
+
31
+ def request_form
32
+ @purpose = purpose
33
+ page("request")
34
+ end
35
+
36
+ def request_proof
37
+ @purpose = purpose
38
+ admitted = intake(:account_request)
39
+ return intake_failure(admitted) if %i[challenge_rejected challenge_unavailable].include?(admitted)
40
+ Rails::Runtime.enqueue_account_request(identifier: admitted, purpose: @purpose) unless admitted.is_a?(Symbol)
41
+ redirect_to "/account/check-email", status: :see_other
42
+ end
43
+
44
+ def proof
45
+ @purpose = purpose
46
+ result = Rails::Runtime.accounts.preview(token: params[:token], purpose: @purpose)
47
+ page(result.success? ? "proof" : "invalid_proof")
48
+ end
49
+
50
+ def consume
51
+ @purpose = purpose
52
+ admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: :account_consume)
53
+ return intake_failure(admitted) if admitted.is_a?(Symbol)
54
+ result = Rails::Runtime.accounts.consume(token: params[:token], purpose: @purpose, password: params[:password])
55
+ if result.success?
56
+ redirect_to "/sign-in", status: :see_other, notice: "Your account has been updated. Sign in to start a new session."
57
+ else
58
+ @error = "This request could not be completed. Check the password requirements or request a new link."
59
+ page("proof", status: :unprocessable_entity)
60
+ end
61
+ end
62
+
63
+ def edit_email = page("email")
64
+ def edit_password = page("password")
65
+ def confirm_deletion = page("delete")
66
+
67
+ def destroy
68
+ result = Rails::Runtime.accounts.delete_account(user: Current.user, session: Current.session)
69
+ if result.success?
70
+ terminate_session
71
+ redirect_to "/sign-in", status: :see_other, notice: "Your account was deleted."
72
+ elsif result.reason == :elevation_required
73
+ redirect_to "/reauthenticate?purpose=delete_account", status: :see_other
74
+ else
75
+ @error = "Your account could not be deleted. Contact support if this continues."
76
+ page("delete", status: :unprocessable_entity)
77
+ end
78
+ end
79
+
80
+ def change_password
81
+ result = Rails::Runtime.accounts.change_password(user: Current.user, session: Current.session, password: params[:password])
82
+ if result.success?
83
+ terminate_session
84
+ redirect_to "/sign-in", status: :see_other, notice: "Your password was changed. Sign in with your new password."
85
+ elsif result.reason == :elevation_required
86
+ redirect_to "/reauthenticate?purpose=change_password", status: :see_other
87
+ else
88
+ @error = "This password cannot be used. Check the password requirements and try again."
89
+ page("password", status: :unprocessable_entity)
90
+ end
91
+ end
92
+
93
+ def change_email
94
+ result = Rails::Runtime.accounts.change_email(user: Current.user, session: Current.session, identifier: params[:email_address])
95
+ if result.success?
96
+ redirect_to "/account/check-email", status: :see_other
97
+ elsif result.reason == :elevation_required
98
+ redirect_to "/reauthenticate?purpose=change_email", status: :see_other
99
+ else
100
+ @error = "This email address cannot be used. Check it and try again."
101
+ page("email", status: :unprocessable_entity)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ # Host controllers may override with an explicit Rails parameter allowlist.
108
+ # The default does not forward arbitrary account/role parameters.
109
+ def registration_profile = {}
110
+
111
+ def require_feature
112
+ head :not_found unless AddAuth.configuration.lifecycle.enabled
113
+ end
114
+
115
+ def purpose
116
+ Core::AccountLifecycle::PURPOSES.include?(params[:purpose]) ? params[:purpose] : "confirm"
117
+ end
118
+
119
+ def intake(action)
120
+ Rails::Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip, action: action, challenge_token: challenge_token)
121
+ end
122
+
123
+ def authentication_partial(partial) = "add_auth/accounts/#{partial}"
124
+
125
+ def service_unavailable
126
+ @error = "Account requests are temporarily unavailable. Please try again shortly."
127
+ response.set_header("Retry-After", "60")
128
+ page("unavailable", status: :service_unavailable)
129
+ end
130
+
131
+ def intake_failure(reason)
132
+ @error = "This request could not be verified. Please try again shortly."
133
+ status = {rate_limited: :too_many_requests, challenge_unavailable: :service_unavailable}.fetch(reason, :unprocessable_entity)
134
+ response.set_header("Retry-After", "300") if %i[too_many_requests service_unavailable].include?(status)
135
+ page("unavailable", status: status)
136
+ end
137
+ end
138
+ end
@@ -20,7 +20,8 @@ module AddAuth
20
20
  end
21
21
 
22
22
  def boot
23
- source = 'if (!window.Turbo) await import("/add_auth/turbo.js"); await import("/add_auth/challenge.js");'
23
+ source = Rails::Runtime.config.turbo_enabled ? 'if (!window.Turbo) await import("/add_auth/turbo.js");' : ""
24
+ source += ' await import("/add_auth/challenge.js");'
24
25
  source += ' await import("/add_auth/passkey.js");' if Rails::Runtime.config.passkeys.enabled
25
26
  render body: source, content_type: "text/javascript"
26
27
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class ExternalIdentitiesController < ::ApplicationController
5
+ include Rails::AuthenticationPages
6
+
7
+ skip_before_action :require_authentication, raise: false
8
+ before_action :private_response
9
+ before_action :require_feature
10
+ before_action :require_add_auth_authentication
11
+ protect_from_forgery with: :exception
12
+ helper AddAuth::SignInsHelper
13
+ layout "add_auth/authentication"
14
+ rescue_from AddAuth::Error, with: :unavailable
15
+
16
+ def index
17
+ load_identities
18
+ page("index")
19
+ end
20
+
21
+ def destroy
22
+ elevation = Rails::Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
23
+ purpose: Core::ExternalIdentities::UNLINK, policy: Rails::Runtime.step_up_policy)
24
+ result = service.unlink(user: Current.user, session: Current.session, identity_id: params[:id], grant: elevation.credential)
25
+ if result.success?
26
+ redirect_to "/account/external-identities", status: :see_other
27
+ elsif result.reason == :elevation_required
28
+ redirect_to "/reauthenticate?purpose=unlink_external_identity", status: :see_other
29
+ else
30
+ @error = (result.reason == :last_credential) ? "Add another usable sign-in method before removing this one." : "This sign-in method could not be changed. Try again."
31
+ load_identities
32
+ page("index", status: :unprocessable_entity)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def service = Rails::Runtime.external_identities
39
+
40
+ def require_feature
41
+ head :not_found unless Rails::Runtime.config.external_identities.enabled
42
+ end
43
+
44
+ def authentication_partial(partial) = "add_auth/external_identities/#{partial}"
45
+
46
+ def load_identities
47
+ @identities = ::AddAuthExternalIdentity.where(user_id: Current.user.id, revoked_at: nil).order(:linked_at)
48
+ @providers = Rails::Runtime.config.external_identities.providers
49
+ end
50
+
51
+ def unavailable
52
+ @error = "External sign-in is temporarily unavailable."
53
+ page("unavailable", status: :service_unavailable)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ # This transport never reads/writes browser cookies or invokes the host's
5
+ # browser Authentication concern. Hosts may map the same Core result to a
6
+ # versioned application DTO through Runtime.mobile_authentication.
7
+ class MobileSessionsController < ActionController::API
8
+ before_action :private_response
9
+ before_action :require_mobile_feature
10
+ before_action :require_native_feature, only: %i[apple_challenge apple apple_enroll]
11
+ before_action :require_mobile_session, except: %i[create exchange apple_challenge apple apple_enroll]
12
+ rescue_from AddAuth::Error, ActiveRecord::ActiveRecordError, with: :unavailable
13
+
14
+ def create
15
+ result = Rails::Runtime.mobile_authentication.password(identifier: params[:email_address], password: params[:password],
16
+ client_id: params[:client_id], ip: request.remote_ip, user_agent: request.user_agent, challenge_token: params[:challenge_token])
17
+ return failure(result.reason) unless result.success?
18
+ created(result)
19
+ end
20
+
21
+ def show
22
+ render json: {session_id: @mobile_session.id, user_id: @mobile_session.user_id, expires_at: @mobile_session.expires_at}
23
+ end
24
+
25
+ def exchange
26
+ admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: :mobile_handoff)
27
+ return failure(admitted) if admitted.is_a?(Symbol)
28
+ result = Rails::Runtime.mobile_handoffs.exchange(code: params[:code], state: params[:state],
29
+ code_verifier: params[:code_verifier], client_id: params[:client_id], ip: request.remote_ip, user_agent: request.user_agent)
30
+ return failure(result.reason) unless result.success?
31
+ created(result)
32
+ end
33
+
34
+ def apple_challenge
35
+ result = Rails::Runtime.native_apple.start(client_id: params[:client_id], ip: request.remote_ip, challenge_token: params[:challenge_token],
36
+ intent: params.fetch(:intent, "sign_in"))
37
+ return failure(result.reason) unless result.success?
38
+ challenge = result.credential
39
+ render json: {challenge_id: challenge.id, nonce: challenge.nonce, expires_at: challenge.expires_at}, status: :created
40
+ end
41
+
42
+ def apple
43
+ result = Rails::Runtime.native_apple.complete(client_id: params[:client_id], challenge_id: params[:challenge_id], nonce: params[:nonce],
44
+ identity_token: params[:identity_token], ip: request.remote_ip, user_agent: request.user_agent)
45
+ return failure(result.reason) unless result.success?
46
+ created(result)
47
+ end
48
+
49
+ def apple_enroll
50
+ result = Rails::Runtime.native_apple.enroll(client_id: params[:client_id], challenge_id: params[:challenge_id], nonce: params[:nonce],
51
+ identity_token: params[:identity_token], ip: request.remote_ip, identifier: params[:email_address])
52
+ return failure(result.reason) unless result.success?
53
+ render json: {status: "confirmation_required"}, status: :accepted
54
+ end
55
+
56
+ def index
57
+ page = Rails::Runtime.sessions.list_page(user: @mobile_user, current_session_id: @mobile_session.id, before: params[:before])
58
+ render json: {sessions: page.entries.map { |entry|
59
+ {id: entry.id, current: entry.current, transport: entry.transport, client_id: entry.client_id,
60
+ authenticated_with: entry.authenticated_with, last_seen_at: entry.last_seen_at, expires_at: entry.expires_at}
61
+ }, next_cursor: page.next_cursor}
62
+ end
63
+
64
+ def destroy
65
+ Rails::Runtime.sessions.revoke(session: @mobile_session)
66
+ head :no_content
67
+ end
68
+
69
+ def revoke
70
+ revoked = Rails::Runtime.sessions.revoke_one(user: @mobile_user, session: @mobile_session, session_id: params[:id])
71
+ head(revoked ? :no_content : :not_found)
72
+ end
73
+
74
+ def revoke_all
75
+ result = Rails::Runtime.revoke_all(user: @mobile_user, session: @mobile_session,
76
+ password: params[:password], ip: request.remote_ip, challenge_token: params[:challenge_token])
77
+ result.success? ? head(:no_content) : failure(result.reason)
78
+ end
79
+
80
+ private
81
+
82
+ def created(result)
83
+ render json: {token: result.grant.bearer, token_type: "Bearer", expires_at: result.session.expires_at,
84
+ session_id: result.session.id, user_id: result.user.id}, status: :created
85
+ end
86
+
87
+ def require_mobile_session
88
+ require "add_auth/core/mobile_authentication"
89
+ bearer = Core::MobileAuthentication.bearer(request.authorization)
90
+ grant = Rails::Runtime.sessions.resume_mobile(bearer: bearer)
91
+ return failure(:invalid_credentials) unless grant
92
+ @mobile_session = grant.session
93
+ @mobile_user = grant.session.user
94
+ end
95
+
96
+ def require_mobile_feature
97
+ head :not_found unless Rails::Runtime.config.mobile.enabled
98
+ end
99
+
100
+ def require_native_feature
101
+ return unavailable unless Rails::Runtime.config.mobile.apple_providers.is_a?(Hash)
102
+ head :not_found if Rails::Runtime.config.mobile.apple_providers.empty?
103
+ end
104
+
105
+ def private_response
106
+ response.set_header("Cache-Control", "no-store")
107
+ response.set_header("Referrer-Policy", "no-referrer")
108
+ head :upgrade_required if ::Rails.env.production? && !request.ssl?
109
+ end
110
+
111
+ def failure(reason)
112
+ status = Core::MobileResponse.status(reason)
113
+ response.set_header("Retry-After", "300") if [429, 503].include?(status)
114
+ response.set_header("WWW-Authenticate", "Bearer") if status == 401
115
+ render json: {error: reason}, status: status
116
+ end
117
+
118
+ def unavailable = failure(:challenge_unavailable)
119
+ end
120
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/provider_libraries/apple_form_post_correlation"
4
+ require "add_auth/rails/provider_libraries/omniauth"
5
+ require "add_auth/rails/provider_libraries/omniauth_correlation"
6
+ require "add_auth/rails/provider_libraries/request_protection"
7
+
8
+ module AddAuth
9
+ # The protocol library has already verified the callback before this
10
+ # controller sees it. This controller only starts/consumes a Core
11
+ # transaction and maps its typed result to the normal Rails session/UI.
12
+ class ProviderSignInsController < ::ApplicationController
13
+ include Rails::AuthenticationPages
14
+
15
+ skip_before_action :require_authentication, raise: false
16
+ before_action :private_response
17
+ before_action :load_provider
18
+ before_action :require_add_auth_authentication, unless: :anonymous_or_callback?
19
+ protect_from_forgery with: :exception, except: :callback
20
+ helper AddAuth::SignInsHelper
21
+ layout "add_auth/authentication"
22
+ rescue_from AddAuth::Error, with: :provider_unavailable
23
+
24
+ # This deliberately renders an ordinary confirmation form instead of
25
+ # redirecting to /auth/:provider: OmniAuth 2 accepts a CSRF-protected POST,
26
+ # and a 303 would turn that navigation into an unsafe GET. It is equally
27
+ # usable with Turbo, inside a frame, and with JavaScript disabled.
28
+ def enrollment
29
+ return head :not_found unless Rails::Runtime.config.lifecycle.enabled
30
+ page("enrollment")
31
+ end
32
+
33
+ def prepare
34
+ return head :not_found if mobile? && !Rails::Runtime.config.mobile.enabled
35
+ if enrollment?
36
+ return head :not_found unless Rails::Runtime.config.lifecycle.enabled
37
+ @enrollment_identifier = Rails::Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip,
38
+ action: :register, challenge_token: challenge_token)
39
+ return provider_intake_failure(@enrollment_identifier) if @enrollment_identifier.is_a?(Symbol)
40
+ else
41
+ admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: (params[:flow].to_s == "reauthenticate") ? :reauthenticate : :provider, challenge_token: challenge_token)
42
+ return provider_intake_failure(admitted) if admitted.is_a?(Symbol)
43
+ end
44
+ if params[:flow].to_s == "link"
45
+ elevation = Rails::Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
46
+ purpose: Core::ExternalIdentities::LINK, policy: Rails::Runtime.step_up_policy)
47
+ return redirect_to "/reauthenticate?purpose=link_external_identity", status: :see_other unless elevation.success?
48
+ end
49
+ pending = service.begin_transaction(configuration_id: @provider.configuration.id,
50
+ browser_secret: browser_secret, **transaction_subject)
51
+ return provider_failure(pending.reason) unless pending.success?
52
+
53
+ if mobile?
54
+ accepted = Rails::Runtime.mobile_handoffs.begin_transaction(pending: pending.credential,
55
+ client_id: params[:client_id], callback: params[:callback], state: params[:state],
56
+ code_challenge: params[:code_challenge], code_challenge_method: params[:code_challenge_method])
57
+ unless accepted.success?
58
+ service.cancel(transaction: pending.credential.id, browser_secret: browser_secret)
59
+ return provider_failure(accepted.reason)
60
+ end
61
+ end
62
+
63
+ if enrollment? && !Rails::Runtime.provider_enrollment.write(pending: pending.credential,
64
+ identifier: @enrollment_identifier, profile: registration_profile)
65
+ service.cancel(transaction: pending.credential.id, browser_secret: browser_secret)
66
+ return provider_failure(:invalid_credentials)
67
+ end
68
+ remember_correlation!(pending.credential)
69
+ @provider_path = "/auth/#{@provider.middleware_name}"
70
+ @omniauth_csrf_parameter = Rails::ProviderLibraries::RequestProtection.parameter
71
+ @omniauth_csrf_token = Rails::ProviderLibraries::RequestProtection.token(session: request.env.fetch("rack.session"),
72
+ rails_token: -> { form_authenticity_token })
73
+ @heading = heading_for(pending.credential.purpose)
74
+ # This token-free URL posts to the host's existing OAuth middleware.
75
+ # Preserve its Rails origin check without sending a referrer off-site.
76
+ response.set_header("Referrer-Policy", "same-origin")
77
+ page("prepare")
78
+ end
79
+
80
+ # OmniAuth performs callback state/nonce/code validation. The correlation
81
+ # middleware has attached the opaque Core transaction only after its
82
+ # state check succeeds; no raw params, profile attributes, or tokens are
83
+ # accepted here. Apple's signed callback cookie supplies that same opaque
84
+ # data when its form_post cannot send the normal SameSite=Lax session.
85
+ def callback
86
+ correlation = request.env[correlation_env_key]
87
+ return provider_failure(:invalid_credentials) unless valid_correlation?(correlation)
88
+ return provider_failure(:invalid_credentials) unless correlation.fetch("configuration_id") == @provider.configuration.id
89
+
90
+ pending = service.pending(transaction: correlation.fetch("transaction"), browser_secret: correlation.fetch("browser_secret"))
91
+ return provider_failure(:invalid_credentials) unless pending && pending.configuration.equal?(@provider.configuration) &&
92
+ pending.purpose.to_s == correlation.fetch("purpose")
93
+
94
+ callback = Rails::ProviderLibraries::OmniAuth::CallbackResult.capture(env: request.env, provider: @provider.middleware_name)
95
+ evidence = pending.configuration.verify(server_result: callback, transaction: pending)
96
+ return provider_failure(:invalid_credentials) unless evidence
97
+
98
+ case pending.purpose
99
+ when Core::ExternalIdentities::MOBILE then finish_mobile(evidence)
100
+ when Core::ExternalIdentities::ENROLL then finish_enrollment(pending, evidence)
101
+ when :sign_in then finish_sign_in(evidence)
102
+ when Core::ExternalIdentities::LINK then finish_link(pending, evidence)
103
+ else finish_reauthentication(pending, evidence)
104
+ end
105
+ ensure
106
+ Rails::Runtime.provider_enrollment.erase(pending: pending) if pending&.purpose == Core::ExternalIdentities::ENROLL
107
+ end
108
+
109
+ private
110
+
111
+ def service = Rails::Runtime.external_identities
112
+
113
+ def load_provider
114
+ return head :not_found unless Rails::Runtime.config.external_identities.enabled
115
+
116
+ @provider = Rails::Runtime.config.external_identities.providers.find { |item| item.middleware_name == params[:provider].to_s }
117
+ head :not_found unless @provider
118
+ end
119
+
120
+ def sign_in? = params[:flow].to_s == "sign_in"
121
+ def mobile? = params[:flow].to_s == "mobile"
122
+
123
+ def enrollment? = params[:flow].to_s == "enroll"
124
+ def anonymous_or_callback? = sign_in? || enrollment? || mobile? || action_name == "callback"
125
+ def registration_profile = {}
126
+
127
+ def transaction_subject
128
+ return {purpose: Core::ExternalIdentities::MOBILE} if mobile?
129
+ return {purpose: Core::ExternalIdentities::ENROLL} if enrollment?
130
+ return {purpose: :sign_in} if sign_in?
131
+ return {user: Current.user, session: Current.session, purpose: Core::ExternalIdentities::LINK} if params[:flow].to_s == "link"
132
+
133
+ {user: Current.user, session: Current.session, purpose: params[:purpose].to_s}
134
+ end
135
+
136
+ def browser_secret
137
+ session[:add_auth_browser] ||= Rails::Runtime.browser_binding.generate
138
+ end
139
+
140
+ def remember_correlation!(pending)
141
+ if @provider.apple_form_post?
142
+ Rails::ProviderLibraries::AppleFormPostCorrelation.remember!(session: session, transaction: pending.id,
143
+ browser_secret: browser_secret, configuration_id: @provider.configuration.id, purpose: pending.purpose, remember: remember?)
144
+ else
145
+ Rails::ProviderLibraries::OmniAuthCorrelation.remember!(session: session, provider: @provider.middleware_name,
146
+ transaction: pending.id, browser_secret: browser_secret, configuration_id: @provider.configuration.id, purpose: pending.purpose, remember: remember?)
147
+ end
148
+ end
149
+
150
+ def correlation_env_key
151
+ @provider.apple_form_post? ? "add_auth.apple_correlation" : Rails::ProviderLibraries::OmniAuthCorrelation::ENV_KEY
152
+ end
153
+
154
+ def valid_correlation?(value)
155
+ value.is_a?(Hash) && %w[transaction browser_secret configuration_id purpose].all? do |key|
156
+ value[key].is_a?(String) && value[key].valid_encoding? && value[key].bytesize.between?(1, 2048)
157
+ end
158
+ end
159
+
160
+ def finish_enrollment(pending, evidence)
161
+ intake = Rails::Runtime.provider_enrollment.read(pending: pending)
162
+ return provider_failure(:invalid_credentials) unless intake
163
+ result = Rails::Runtime.accounts.register_external(evidence: evidence, **intake)
164
+ return provider_failure(result.reason) unless result.success?
165
+ redirect_to "/account/check-email", status: :see_other
166
+ end
167
+
168
+ def provider_intake_failure(reason)
169
+ status = {rate_limited: :too_many_requests, challenge_unavailable: :service_unavailable}.fetch(reason, :unprocessable_entity)
170
+ @error = "This request could not be verified. Please try again shortly."
171
+ response.set_header("Retry-After", "300") if %i[rate_limited challenge_unavailable].include?(reason)
172
+ page("failure", status: status)
173
+ end
174
+
175
+ def finish_sign_in(evidence)
176
+ result = service.sign_in(evidence: evidence, replacing: add_auth_replacement_session,
177
+ **add_auth_session_hints.merge(remember: correlation_remember?))
178
+ return provider_failure(result.reason) unless result.success?
179
+
180
+ destination = after_authentication_url
181
+ add_auth_accept(result.grant)
182
+ redirect_to destination, status: :see_other
183
+ end
184
+
185
+ def finish_mobile(evidence)
186
+ result = service.mobile_handoff(evidence: evidence, handoffs: Rails::Runtime.mobile_handoffs)
187
+ return provider_failure(result.reason) unless result.success?
188
+ handoff = result.credential
189
+ redirect_to "#{handoff.callback}?#{URI.encode_www_form(code: handoff.code, state: handoff.state)}", status: :see_other, allow_other_host: true
190
+ end
191
+
192
+ def finish_link(pending, evidence)
193
+ account, current = correlated_session(pending)
194
+ return provider_failure(:elevation_required) unless account && current
195
+
196
+ elevation = Rails::Runtime.sessions.with_elevation(user: account, session: current,
197
+ purpose: Core::ExternalIdentities::LINK, policy: Rails::Runtime.step_up_policy)
198
+ result = service.link(user: account, session: current, evidence: evidence, grant: elevation.credential)
199
+ return provider_failure(result.reason) unless result.success?
200
+
201
+ redirect_to "/account/external-identities", status: :see_other
202
+ end
203
+
204
+ def finish_reauthentication(pending, evidence)
205
+ account, current = correlated_session(pending)
206
+ return provider_failure(:elevation_required) unless account && current
207
+
208
+ result = service.reauthenticate(user: account, session: current, evidence: evidence)
209
+ return provider_failure(result.reason) unless result.success?
210
+
211
+ grant = Rails::Runtime.sessions.rotate_for_step_up(user: account, session: current, grant: result.credential)
212
+ return provider_failure(:elevation_required) unless grant
213
+
214
+ add_auth_accept(grant)
215
+ redirect_to Rails::Runtime.step_up_policy.return_to(pending.purpose), status: :see_other
216
+ end
217
+
218
+ # A form_post callback can correctly correlate an authenticated transaction
219
+ # while lacking the normal app cookie. Rehydrate only the exact session row
220
+ # recorded by Core; Core rechecks that row's digest and policy version under
221
+ # lock before link/reauthentication can mutate authority.
222
+ def correlated_session(pending)
223
+ account = ::User.find_by(id: pending.user_id)
224
+ current = account && ::Session.find_by(id: pending.session_id, user_id: account.id, token_digest: pending.session_digest)
225
+ [account, current]
226
+ end
227
+
228
+ def provider_failure(_reason)
229
+ @error = "We could not verify this provider sign-in. Please try again."
230
+ page("failure", status: :unprocessable_entity)
231
+ end
232
+
233
+ def provider_unavailable
234
+ @error = "Provider sign-in is temporarily unavailable. Please try again shortly."
235
+ page("failure", status: :service_unavailable)
236
+ end
237
+
238
+ def heading_for(purpose)
239
+ [:sign_in, Core::ExternalIdentities::ENROLL, Core::ExternalIdentities::MOBILE].include?(purpose) ? "Continue to #{@provider.label}" : "Confirm with #{@provider.label}"
240
+ end
241
+
242
+ def correlation_remember? = request.env[correlation_env_key].fetch("remember") == true
243
+
244
+ def remember? = sign_in? && params[:remember].to_s == "1"
245
+
246
+ def authentication_partial(partial) = "add_auth/provider_sign_ins/#{partial}"
247
+ end
248
+ end
@@ -14,6 +14,7 @@ module AddAuth
14
14
 
15
15
  def add_auth_challenge_form_data(action)
16
16
  data = {turbo_frame: "_top"}
17
+ data[:turbo] = false unless AddAuth.configuration.turbo_enabled
17
18
  return data unless AddAuth.configuration.challenge_on.include?(action)
18
19
  provider = AddAuth.configuration.challenge
19
20
  return data unless provider.site_key
@@ -23,7 +24,7 @@ module AddAuth
23
24
  else return data
24
25
  end
25
26
  data.merge(controller: "add-auth-challenge",
26
- action: "submit->add-auth-challenge#submit add_auth:proof-used->add-auth-challenge#reset turbo:submit-end->add-auth-challenge#reset turbo:before-cache@document->add-auth-challenge#beforeCache",
27
+ action: "submit->add-auth-challenge#submit add_auth:proof-used->add-auth-challenge#reset turbo:submit-end->add-auth-challenge#reset turbo:before-cache@document->add-auth-challenge#beforeCache pagehide@window->add-auth-challenge#beforeCache pageshow@window->add-auth-challenge#restore",
27
28
  add_auth_challenge_provider_value: kind, add_auth_challenge_site_key_value: provider.site_key,
28
29
  add_auth_challenge_action_value: (provider.respond_to?(:expected_action) && provider.expected_action) || action,
29
30
  add_auth_challenge_script_url_value: provider.script_url)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class AccountDeliveryJob < DeliveryJob
5
+ def perform(id)
6
+ record = ::AddAuthAccountToken.find_by(id: id)
7
+ return unless record
8
+ deliver(service: Rails::Runtime.accounts, record: record, mailer: AccountMailer) do |delivery|
9
+ AccountMailer.link(recipient: delivery.fetch(:recipient), purpose: delivery.fetch(:purpose),
10
+ url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose)))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class AccountRequestJob < ActiveJob::Base
5
+ self.log_arguments = false
6
+ retry_on StandardError, wait: :polynomially_longer, attempts: 8
7
+
8
+ def perform(encrypted_identifier)
9
+ payload = Rails::Runtime.decrypt_intake(encrypted_identifier)
10
+ if payload.is_a?(Hash)
11
+ Rails::Runtime.accounts.issue(identifier: payload["identifier"], purpose: payload["purpose"], request_id: job_id)
12
+ end
13
+ record = ::AddAuthAccountToken.find_by(request_id: job_id)
14
+ AccountDeliveryJob.perform_later(record.id) if record
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class AccountMailer < SignInMailer
5
+ def link(recipient:, url:, purpose:)
6
+ @instruction = {"confirm" => "Confirm your email address", "reset_password" => "Choose a new password", "unlock" => "Unlock your account"}.fetch(purpose)
7
+ @url = url
8
+ mail(to: recipient, from: Rails::Runtime.config.mail_from, subject: @instruction,
9
+ content_type: "text/plain").extend(SignInMailer::DeliveryReceipt)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ <%= @instruction %>
2
+
3
+ Open this link, then use the button on the page to confirm your request:
4
+ <%= @url %>
5
+
6
+ This link expires in <%= AddAuth.configuration.lifecycle.proof_lifetime.to_i / 60 %> minutes and can be used once.
7
+ If you did not request this message, you can ignore it.
@@ -0,0 +1,5 @@
1
+ <% content_for :title, "Check your email" %>
2
+ <h1 id="add_auth-title">Check your email</h1>
3
+ <p role="status">If this request is available for your account, we’ll send a link with the next step.</p>
4
+ <p class="<%= add_auth_class(:muted) %>">Check your spam folder too. Only the latest link for this request will work.</p>
5
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,6 @@
1
+ <% content_for :title, "Delete your account" %>
2
+ <h1 id="add_auth-title">Delete your account</h1>
3
+ <p>Your account will be deleted and all sessions signed out. Verify it’s you, then return here to confirm.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= button_to "Delete my account", "/account", method: :delete, class: add_auth_class(:button), form: {data: {turbo_frame: "_top"}} %>
6
+ <p><%= link_to "Keep my account", "/sessions" %></p>