add_auth 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +38 -14
  4. data/ROADMAP.md +87 -38
  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 +70 -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/runtime.rb +170 -8
  83. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  84. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  85. data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
  86. data/lib/add_auth/rails/stores/authority.rb +28 -0
  87. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  88. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  89. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  90. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  91. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  92. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  93. data/lib/add_auth/result.rb +1 -0
  94. data/lib/add_auth/version.rb +1 -1
  95. data/lib/add_auth.rb +4 -0
  96. data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
  97. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  98. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  99. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  100. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  101. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  102. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  103. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  104. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  105. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  106. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  107. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  109. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  110. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  111. data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
  112. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  113. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  114. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  115. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  116. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  117. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  118. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  119. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  120. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  121. data/lib/tasks/add_auth.rake +18 -1
  122. metadata +85 -2
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "active_support/message_encryptor"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ # Bounded transport storage for the form collected before provider consent.
9
+ # This grants no identity/account authority: G3/G4 still consume the exact
10
+ # verified enrollment transaction inside the new-account transaction.
11
+ class ProviderEnrollment
12
+ MAX_BYTES = 4096
13
+
14
+ def initialize(model:, digest:, key:)
15
+ @model, @digest = model, digest
16
+ @cipher = ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: :json)
17
+ end
18
+
19
+ def write(pending:, identifier:, profile:)
20
+ return false unless enrollment?(pending) && bounded_profile?(profile) && identifier.is_a?(String)
21
+ payload = {"identifier" => identifier, "profile" => profile}
22
+ return false unless JSON.generate(payload).bytesize <= MAX_BYTES
23
+ ciphertext = @cipher.encrypt_and_sign(payload, purpose: purpose(pending), expires_at: pending.expires_at)
24
+ row(pending).where(enrollment_payload: nil).update_all(enrollment_payload: ciphertext) == 1
25
+ rescue JSON::GeneratorError, EncodingError
26
+ false
27
+ end
28
+
29
+ def read(pending:)
30
+ return unless enrollment?(pending)
31
+ ciphertext = row(pending).pick(:enrollment_payload)
32
+ return unless ciphertext.is_a?(String) && ciphertext.bytesize <= MAX_BYTES * 3
33
+ payload = @cipher.decrypt_and_verify(ciphertext, purpose: purpose(pending))
34
+ return unless payload.is_a?(Hash) && payload["identifier"].is_a?(String) && bounded_profile?(payload["profile"])
35
+ {identifier: payload.fetch("identifier"), profile: payload.fetch("profile").symbolize_keys}
36
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
37
+ nil
38
+ end
39
+
40
+ def erase(pending:)
41
+ row(pending).update_all(enrollment_payload: nil) if enrollment?(pending)
42
+ end
43
+
44
+ private
45
+
46
+ def enrollment?(pending)
47
+ pending.is_a?(Core::ExternalIdentities::Pending) && pending.purpose == Core::ExternalIdentities::ENROLL
48
+ end
49
+
50
+ def bounded_profile?(profile)
51
+ profile.is_a?(Hash) && profile.size <= 20 && profile.all? do |name, value|
52
+ (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.valid_encoding? && name.to_s.bytesize.between?(1, 64) &&
53
+ (value.nil? || value == true || value == false || (value.is_a?(String) && value.valid_encoding? && value.bytesize <= 2048))
54
+ end
55
+ end
56
+
57
+ def purpose(pending) = "add_auth.external-enrollment:#{@digest.digest(pending.id)}"
58
+ def row(pending) = @model.where(digest: @digest.digest(pending.id))
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_encryptor"
4
+ require "rack/request"
5
+ require "rack/utils"
6
+
7
+ module AddAuth
8
+ module Rails
9
+ module ProviderLibraries
10
+ # OmniAuth's Apple strategy binds state and nonce to rack.session. Apple
11
+ # posts its callback cross-site, where a normal SameSite=Lax Rails session
12
+ # is intentionally absent. This tiny Rack wrapper copies only those
13
+ # verifier inputs and the Core browser correlation into a separately
14
+ # encrypted, HttpOnly, Secure, SameSite=None cookie scoped to Apple's
15
+ # callback path. It never changes the application's normal session cookie.
16
+ class AppleFormPostCorrelation
17
+ COOKIE = "add_auth_apple_callback"
18
+ PENDING_KEY = "add_auth.apple_pending"
19
+ START_PATH = "/auth/apple"
20
+ CALLBACK_PATH = "/auth/apple/callback"
21
+ PURPOSE = "add_auth.apple-form-post.v1"
22
+
23
+ def self.remember!(session:, transaction:, browser_secret:, configuration_id:, purpose:, remember: false)
24
+ raise ArgumentError, "Apple provider transaction is invalid" unless text?(transaction)
25
+ raise ArgumentError, "Apple provider browser secret is invalid" unless text?(browser_secret)
26
+ raise ArgumentError, "Apple provider configuration is invalid" unless text?(configuration_id)
27
+ raise ArgumentError, "Apple provider purpose is invalid" unless text?(purpose.to_s)
28
+
29
+ session[PENDING_KEY] = {"transaction" => transaction, "browser_secret" => browser_secret,
30
+ "configuration_id" => configuration_id, "purpose" => purpose.to_s, "remember" => remember == true}
31
+ end
32
+
33
+ def self.text?(value)
34
+ value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
35
+ end
36
+ private_class_method :text?
37
+
38
+ def initialize(app, key:, lifetime: 600, clock: -> { Time.now })
39
+ raise ArgumentError, "Apple callback key must be 32 bytes" unless key.is_a?(String) && key.bytesize == 32
40
+ raise ArgumentError, "Apple callback lifetime must be positive" unless lifetime.is_a?(Integer) && lifetime.positive?
41
+
42
+ @app = app
43
+ @cipher = ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: :json)
44
+ @lifetime, @clock = lifetime, clock
45
+ end
46
+
47
+ def call(env)
48
+ request = Rack::Request.new(env)
49
+ return request.post? ? start(env) : @app.call(env) if request.path_info == START_PATH
50
+ return callback(env, request) if request.path_info == CALLBACK_PATH
51
+
52
+ @app.call(env)
53
+ end
54
+
55
+ private
56
+
57
+ def start(env)
58
+ pending = env.fetch("rack.session", {}).delete(PENDING_KEY)
59
+ return @app.call(env) if pending.nil?
60
+ return failure unless pending.is_a?(Hash) && valid_pending?(pending)
61
+
62
+ status, headers, body = @app.call(env)
63
+ return [status, headers, body] unless status.between?(300, 399)
64
+
65
+ payload = pending.merge("state" => env.fetch("rack.session", {})["omniauth.state"],
66
+ "nonce" => env.fetch("rack.session", {})["omniauth.nonce"])
67
+ return [status, headers, body] unless valid_payload?(payload)
68
+
69
+ write_cookie(headers, encrypt(payload))
70
+ [status, headers, body]
71
+ end
72
+
73
+ def callback(env, request)
74
+ # Do not claim a host-owned Apple callback without our capsule. A
75
+ # request reaching AddAuth's controller still needs valid correlation.
76
+ return @app.call(env) unless request.cookies.key?(COOKIE)
77
+ payload = decrypt(request.cookies[COOKIE])
78
+ return failure unless valid_payload?(payload)
79
+
80
+ session = env.fetch("rack.session", {})
81
+ session["omniauth.state"] = payload.fetch("state")
82
+ session["omniauth.nonce"] = payload.fetch("nonce")
83
+ env["add_auth.apple_correlation"] = payload.slice("transaction", "browser_secret", "configuration_id", "purpose", "remember")
84
+ status, headers, body = @app.call(env)
85
+ clear_cookie(headers)
86
+ [status, headers, body]
87
+ end
88
+
89
+ def valid_pending?(payload)
90
+ self.class.send(:text?, payload["transaction"]) && self.class.send(:text?, payload["browser_secret"]) &&
91
+ self.class.send(:text?, payload["configuration_id"]) && self.class.send(:text?, payload["purpose"]) &&
92
+ [true, false].include?(payload["remember"])
93
+ end
94
+
95
+ def valid_payload?(payload)
96
+ payload.is_a?(Hash) && valid_pending?(payload) && self.class.send(:text?, payload["state"]) && self.class.send(:text?, payload["nonce"])
97
+ end
98
+
99
+ def encrypt(payload)
100
+ @cipher.encrypt_and_sign(payload, purpose: PURPOSE, expires_in: @lifetime)
101
+ end
102
+
103
+ def decrypt(value)
104
+ return unless self.class.send(:text?, value)
105
+
106
+ @cipher.decrypt_and_verify(value, purpose: PURPOSE)
107
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
108
+ nil
109
+ end
110
+
111
+ def write_cookie(headers, value)
112
+ Rack::Utils.set_cookie_header!(headers, COOKIE, value: value, path: CALLBACK_PATH, secure: true,
113
+ httponly: true, same_site: :none, max_age: @lifetime)
114
+ end
115
+
116
+ def clear_cookie(headers)
117
+ Rack::Utils.set_cookie_header!(headers, COOKIE, value: "", path: CALLBACK_PATH, secure: true,
118
+ httponly: true, same_site: :none, max_age: 0)
119
+ end
120
+
121
+ def failure
122
+ [422, {"content-type" => "text/plain", "cache-control" => "no-store"}, ["Provider sign-in could not be verified."]]
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ module ProviderLibraries
9
+ class AppleJwks
10
+ URL = URI("https://appleid.apple.com/auth/keys").freeze
11
+ CACHE_KEY = "add_auth:apple:jwks:v1"
12
+ MAX_BYTES = 65_536
13
+
14
+ def initialize(cache:)
15
+ @cache = cache
16
+ end
17
+
18
+ # ruby-jwt's key finder requests one invalidation on an unknown kid.
19
+ # No request/header claim selects a URL or changes TLS verification.
20
+ def call(options = {})
21
+ cached = cache_operation { @cache.read(CACHE_KEY) } unless options[:invalidate]
22
+ return cached if valid?(cached)
23
+ keys = fetch
24
+ cache_operation { @cache.write(CACHE_KEY, keys, expires_in: 3600) }
25
+ keys
26
+ end
27
+
28
+ private
29
+
30
+ def cache_operation
31
+ yield
32
+ rescue
33
+ raise AddAuth::Error, "Apple signing keys unavailable", cause: nil
34
+ end
35
+
36
+ def fetch
37
+ body = +""
38
+ Timeout.timeout(5) do
39
+ Net::HTTP.start(URL.host, URL.port, use_ssl: true, open_timeout: 2, read_timeout: 3, write_timeout: 3) do |http|
40
+ http.max_retries = 0
41
+ http.request(Net::HTTP::Get.new(URL.request_uri)) do |response|
42
+ raise AddAuth::Error, "Apple signing keys unavailable" unless response.code == "200"
43
+ response.read_body do |part|
44
+ raise AddAuth::Error, "Apple signing keys unavailable" if body.bytesize + part.bytesize > MAX_BYTES
45
+ body << part
46
+ end
47
+ end
48
+ end
49
+ end
50
+ keys = JSON.parse(body)
51
+ raise AddAuth::Error, "Apple signing keys unavailable" unless valid?(keys)
52
+ keys
53
+ rescue JSON::ParserError, Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError
54
+ raise AddAuth::Error, "Apple signing keys unavailable", cause: nil
55
+ end
56
+
57
+ def valid?(value)
58
+ value.is_a?(Hash) && value["keys"].is_a?(Array) && value["keys"].size.between?(1, 16) &&
59
+ value["keys"].all? { |key|
60
+ key.is_a?(Hash) && key["kty"] == "RSA" && key["alg"] == "RS256" &&
61
+ key["kid"].is_a?(String) && key["kid"].bytesize.between?(1, 128) && key["n"].is_a?(String) && key["n"].bytesize.between?(1, 1024) &&
62
+ key["e"].is_a?(String) && key["e"].bytesize.between?(1, 16)
63
+ } && value["keys"].map { |key| key["kid"] }.uniq.length == value["keys"].length
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional adapter: the host installs and requires ruby-jwt. No OAuth strategy,
4
+ # global library setting or provider credential storage is introduced.
5
+ module AddAuth
6
+ module Rails
7
+ module ProviderLibraries
8
+ module AppleNative
9
+ ISSUER = "https://appleid.apple.com"
10
+ PURPOSES = [Core::ExternalIdentities::NATIVE, Core::ExternalIdentities::NATIVE_ENROLL].freeze
11
+
12
+ class CallbackResult
13
+ def self.capture(identity_token:, pending:, nonce:, jwks:)
14
+ return unless defined?(::JWT) && pending.is_a?(Core::ExternalIdentities::Pending) &&
15
+ PURPOSES.include?(pending.purpose) && pending.configuration.issuer == ISSUER &&
16
+ identity_token.is_a?(String) && identity_token.bytesize.between?(1, 16_384) && nonce.is_a?(String)
17
+ claims, = ::JWT.decode(identity_token, nil, true, algorithms: ["RS256"], jwks: jwks, allow_nil_kid: false,
18
+ iss: ISSUER, verify_iss: true, aud: pending.configuration.audience, verify_aud: true, verify_iat: true,
19
+ required_claims: %w[iss aud sub exp iat nonce])
20
+ signed_nonce = claims["nonce"]
21
+ return unless signed_nonce.is_a?(String) && signed_nonce.bytesize == nonce.bytesize &&
22
+ OpenSSL.fixed_length_secure_compare(signed_nonce, nonce) && claims["iat"].is_a?(Integer) && claims["exp"].is_a?(Integer) &&
23
+ claims["iat"] >= pending.issued_at.to_i && claims["aud"] == pending.configuration.audience
24
+ new(pending: pending, subject: claims["sub"])
25
+ rescue ::JWT::DecodeError, ArgumentError
26
+ nil
27
+ end
28
+
29
+ def initialize(pending:, subject:)
30
+ @transaction_id = pending.id
31
+ @configuration = pending.configuration
32
+ @purpose = pending.purpose
33
+ @subject = Core::ExternalIdentities::Configuration.text(subject)
34
+ freeze
35
+ end
36
+ private_class_method :new
37
+
38
+ def verified_claims(transaction:)
39
+ return unless transaction.is_a?(Core::ExternalIdentities::Pending) && transaction.id == @transaction_id &&
40
+ transaction.configuration.equal?(@configuration) && transaction.purpose == @purpose
41
+ {issuer: ISSUER, audience: @configuration.audience, subject: @subject, provenance: "ruby-jwt/apple-native-v1"}
42
+ end
43
+
44
+ def inspect = "#<AddAuth::Rails::ProviderLibraries::AppleNative::CallbackResult [FILTERED]>"
45
+ end
46
+
47
+ class Verifier
48
+ def call(server_result:, transaction:)
49
+ server_result.verified_claims(transaction: transaction) if server_result.is_a?(CallbackResult)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module ProviderLibraries
6
+ # Routing selects only host-registered AddAuth providers. Authentication
7
+ # and transaction ownership are checked later by the library and Core.
8
+ class CallbackRoute
9
+ def self.matches?(request)
10
+ options = AddAuth.configuration.external_identities
11
+ options.enabled && options.providers.any? { |provider| provider.middleware_name == request.path_parameters[:provider] }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -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