add_auth 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +38 -14
- data/ROADMAP.md +87 -38
- data/SECURITY.md +6 -0
- data/app/controllers/add_auth/accounts_controller.rb +138 -0
- data/app/controllers/add_auth/assets_controller.rb +2 -1
- data/app/controllers/add_auth/external_identities_controller.rb +56 -0
- data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
- data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
- data/app/jobs/add_auth/account_delivery_job.rb +14 -0
- data/app/jobs/add_auth/account_request_job.rb +17 -0
- data/app/mailers/add_auth/account_mailer.rb +12 -0
- data/app/views/add_auth/account_mailer/link.text.erb +7 -0
- data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
- data/app/views/add_auth/accounts/_delete.html.erb +6 -0
- data/app/views/add_auth/accounts/_email.html.erb +12 -0
- data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
- data/app/views/add_auth/accounts/_password.html.erb +12 -0
- data/app/views/add_auth/accounts/_proof.html.erb +16 -0
- data/app/views/add_auth/accounts/_register.html.erb +17 -0
- data/app/views/add_auth/accounts/_request.html.erb +14 -0
- data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
- data/app/views/add_auth/external_identities/_index.html.erb +22 -0
- data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
- data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
- data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
- data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
- data/app/views/add_auth/sessions/_list.html.erb +1 -1
- data/app/views/add_auth/sessions/index.html.erb +7 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
- data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
- data/app/views/layouts/add_auth/authentication.html.erb +4 -3
- data/exe/add_auth-preflight +26 -0
- data/lib/add_auth/configuration.rb +78 -4
- data/lib/add_auth/core/access_policy.rb +11 -1
- data/lib/add_auth/core/account_lifecycle.rb +251 -0
- data/lib/add_auth/core/account_policy.rb +50 -0
- data/lib/add_auth/core/external_identities/evidence.rb +98 -0
- data/lib/add_auth/core/external_identities.rb +278 -0
- data/lib/add_auth/core/intake.rb +6 -1
- data/lib/add_auth/core/maintenance.rb +5 -3
- data/lib/add_auth/core/migration/account_adoption.rb +79 -0
- data/lib/add_auth/core/migration/readiness.rb +41 -0
- data/lib/add_auth/core/mobile_authentication.rb +35 -0
- data/lib/add_auth/core/mobile_handoffs.rb +83 -0
- data/lib/add_auth/core/mobile_profile.rb +46 -0
- data/lib/add_auth/core/mobile_response.rb +12 -0
- data/lib/add_auth/core/native_authentication.rb +77 -0
- data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
- data/lib/add_auth/core/passwords/credential.rb +49 -0
- data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
- data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
- data/lib/add_auth/core/remaining_factors.rb +24 -0
- data/lib/add_auth/core/sessions.rb +102 -17
- data/lib/add_auth/core/step_up.rb +13 -1
- data/lib/add_auth/migration/static_inventory.rb +116 -0
- data/lib/add_auth/rails/account_password_entry.rb +24 -0
- data/lib/add_auth/rails/authentication.rb +1 -1
- data/lib/add_auth/rails/authentication_pages.rb +5 -0
- data/lib/add_auth/rails/doctor.rb +70 -3
- data/lib/add_auth/rails/ejection.rb +1 -1
- data/lib/add_auth/rails/engine.rb +6 -1
- data/lib/add_auth/rails/migration/account_store.rb +41 -0
- data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
- data/lib/add_auth/rails/migration/key_type.rb +23 -0
- data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
- data/lib/add_auth/rails/password_adoption.rb +37 -0
- data/lib/add_auth/rails/provider_enrollment.rb +61 -0
- data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
- data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
- data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
- data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
- data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
- data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
- data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
- data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
- data/lib/add_auth/rails/runtime.rb +170 -8
- data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
- data/lib/add_auth/rails/stores/account_lock.rb +16 -7
- data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
- data/lib/add_auth/rails/stores/authority.rb +28 -0
- data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
- data/lib/add_auth/rails/stores/external_identities.rb +91 -0
- data/lib/add_auth/rails/stores/maintenance.rb +1 -1
- data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
- data/lib/add_auth/rails/stores/security_events.rb +3 -6
- data/lib/add_auth/rails/user_lifecycle.rb +8 -10
- data/lib/add_auth/result.rb +1 -0
- data/lib/add_auth/version.rb +1 -1
- data/lib/add_auth.rb +4 -0
- data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
- data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
- data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
- data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
- data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
- data/lib/tasks/add_auth.rake +18 -1
- metadata +85 -2
|
@@ -0,0 +1,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
|