standard_id 0.7.1 → 0.8.1
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/app/controllers/concerns/standard_id/inertia_rendering.rb +16 -1
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +72 -0
- data/app/controllers/concerns/standard_id/social_authentication.rb +42 -1
- data/app/controllers/concerns/standard_id/web_authentication.rb +7 -1
- data/app/controllers/concerns/standard_id/web_mechanism_gate.rb +28 -0
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +20 -4
- data/app/controllers/standard_id/web/base_controller.rb +1 -0
- data/app/controllers/standard_id/web/login_controller.rb +10 -2
- data/app/controllers/standard_id/web/login_verify_controller.rb +16 -11
- data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +1 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +1 -0
- data/app/controllers/standard_id/web/sessions_controller.rb +1 -0
- data/app/controllers/standard_id/web/signup_controller.rb +10 -2
- data/app/controllers/standard_id/web/verify_email/base_controller.rb +1 -0
- data/app/controllers/standard_id/web/verify_phone/base_controller.rb +1 -0
- data/app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb +16 -0
- data/app/models/concerns/standard_id/account_associations.rb +1 -0
- data/app/models/standard_id/refresh_token.rb +86 -0
- data/app/models/standard_id/session.rb +12 -0
- data/config/brakeman.ignore +10 -10
- data/db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb +15 -0
- data/db/migrate/20260311000000_create_standard_id_refresh_tokens.rb +20 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +1 -0
- data/lib/standard_id/config/schema.rb +28 -0
- data/lib/standard_id/engine.rb +14 -0
- data/lib/standard_id/errors.rb +21 -0
- data/lib/standard_id/events/definitions.rb +8 -2
- data/lib/standard_id/http_client.rb +54 -8
- data/lib/standard_id/jwt_service.rb +6 -2
- data/lib/standard_id/oauth/refresh_token_flow.rb +66 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +31 -2
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +13 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6e84ca4e7d4d01aa3957ed1371ef774fc1203b9006da7591b99e20fadb343b4
|
|
4
|
+
data.tar.gz: cc74bfcf5f471d4bf7b76d621497598fac95fa0f833b256a74c3a4374456850b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 21932b0dd06f986340284587ccfae60cfea9b0e5075cdc670ff297e23412c0440d0e434e3974f98f65b9071fb15a118c3c36920e08b8887e9857fa612318e857
|
|
7
|
+
data.tar.gz: 7acf89c15d302da9e25da25de4c786441df085f65950a02e8b6fec71b4a90cf3f72c3167929371018869cc4787e6476d5df78036e3b2dde4833525d5c260045f
|
|
@@ -42,8 +42,23 @@ module StandardId
|
|
|
42
42
|
social_providers: {
|
|
43
43
|
google_enabled: StandardId.config.google_client_id.present?,
|
|
44
44
|
apple_enabled: StandardId.config.apple_client_id.present?
|
|
45
|
-
}
|
|
45
|
+
},
|
|
46
|
+
enabled_mechanisms: web_enabled_mechanisms
|
|
46
47
|
}.deep_merge(additional_props)
|
|
47
48
|
end
|
|
49
|
+
|
|
50
|
+
def web_enabled_mechanisms
|
|
51
|
+
web = StandardId.config.web
|
|
52
|
+
{
|
|
53
|
+
password_login: web.password_login,
|
|
54
|
+
signup: web.signup,
|
|
55
|
+
passwordless_login: web.passwordless_login,
|
|
56
|
+
social_login: web.social_login,
|
|
57
|
+
password_reset: web.password_reset,
|
|
58
|
+
email_verification: web.email_verification,
|
|
59
|
+
phone_verification: web.phone_verification,
|
|
60
|
+
sessions_management: web.sessions_management
|
|
61
|
+
}
|
|
62
|
+
end
|
|
48
63
|
end
|
|
49
64
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module LifecycleHooks
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# Invoke the after_sign_in hook if configured.
|
|
8
|
+
#
|
|
9
|
+
# @param account [Object] the authenticated account
|
|
10
|
+
# @param context [Hash] context about the sign-in
|
|
11
|
+
# - :connection [String] "email", "password", or "social"
|
|
12
|
+
# - :provider [String, nil] e.g. "google", "apple", or nil
|
|
13
|
+
# - :first_sign_in [Boolean] whether this is the account's first browser session
|
|
14
|
+
# @return [String, nil] redirect path override, or nil for default
|
|
15
|
+
# @raise [StandardId::AuthenticationDenied] to reject the sign-in
|
|
16
|
+
def invoke_after_sign_in(account, context)
|
|
17
|
+
hook = StandardId.config.after_sign_in
|
|
18
|
+
return nil unless hook.respond_to?(:call)
|
|
19
|
+
|
|
20
|
+
context = context.merge(first_sign_in: first_sign_in?(account))
|
|
21
|
+
hook.call(account, request, context)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Invoke the after_account_created hook if configured.
|
|
25
|
+
#
|
|
26
|
+
# @param account [Object] the newly created account
|
|
27
|
+
# @param context [Hash] context about the creation
|
|
28
|
+
# - :mechanism [String] "passwordless", "social", or "signup"
|
|
29
|
+
# - :provider [String, nil] e.g. "google", "apple", or nil
|
|
30
|
+
# @return [void]
|
|
31
|
+
def invoke_after_account_created(account, context)
|
|
32
|
+
hook = StandardId.config.after_account_created
|
|
33
|
+
return unless hook.respond_to?(:call)
|
|
34
|
+
|
|
35
|
+
hook.call(account, request, context)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Determine if this is the account's first browser session.
|
|
39
|
+
# A count of 1 means the session just created is the only one.
|
|
40
|
+
def first_sign_in?(account)
|
|
41
|
+
account.sessions.where(type: "StandardId::BrowserSession").active.count <= 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Handle AuthenticationDenied by revoking the session and redirecting to login.
|
|
45
|
+
# If the account was just created, clean it up to avoid orphaned records.
|
|
46
|
+
#
|
|
47
|
+
# @param error [StandardId::AuthenticationDenied] the denial error
|
|
48
|
+
# @param account [Object, nil] the account to clean up if newly created
|
|
49
|
+
# @param newly_created [Boolean] whether the account was created during this request
|
|
50
|
+
def handle_authentication_denied(error, account: nil, newly_created: false)
|
|
51
|
+
session_manager.revoke_current_session!
|
|
52
|
+
destroy_newly_created_account(account) if newly_created
|
|
53
|
+
message = error.message
|
|
54
|
+
# When raised without arguments, StandardError#message returns the class name
|
|
55
|
+
message = "Sign-in was denied" if message.blank? || message == error.class.name
|
|
56
|
+
redirect_to StandardId::WebEngine.routes.url_helpers.login_path, alert: message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Destroy a newly created account and all its dependents.
|
|
60
|
+
# Used when after_sign_in rejects a just-created account to avoid orphans.
|
|
61
|
+
def destroy_newly_created_account(account)
|
|
62
|
+
return unless account&.persisted?
|
|
63
|
+
|
|
64
|
+
ActiveRecord::Base.transaction do
|
|
65
|
+
account.sessions.destroy_all
|
|
66
|
+
account.identifiers.each { |i| i.credentials.destroy_all }
|
|
67
|
+
account.identifiers.destroy_all
|
|
68
|
+
account.destroy
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -6,6 +6,8 @@ module StandardId
|
|
|
6
6
|
prepend_before_action :prepare_provider
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
VALID_LINK_STRATEGIES = %i[strict trust_provider].freeze
|
|
10
|
+
|
|
9
11
|
private
|
|
10
12
|
|
|
11
13
|
attr_reader :provider
|
|
@@ -39,13 +41,16 @@ module StandardId
|
|
|
39
41
|
identifier = StandardId::EmailIdentifier.find_by(value: email)
|
|
40
42
|
|
|
41
43
|
if identifier.present?
|
|
44
|
+
validate_social_link!(identifier, provider)
|
|
45
|
+
identifier.update!(provider: provider.provider_name) if identifier.provider.nil?
|
|
42
46
|
emit_social_account_linked(identifier.account, provider, identifier)
|
|
43
47
|
identifier.account
|
|
44
48
|
else
|
|
45
49
|
account = build_account_from_social(social_info)
|
|
46
50
|
identifier = StandardId::EmailIdentifier.create!(
|
|
47
51
|
account: account,
|
|
48
|
-
value: email
|
|
52
|
+
value: email,
|
|
53
|
+
provider: provider.provider_name
|
|
49
54
|
)
|
|
50
55
|
identifier.verify! if identifier.respond_to?(:verify!) && [true, "true"].include?(social_info[:email_verified])
|
|
51
56
|
emit_social_account_created(account, provider, social_info)
|
|
@@ -53,6 +58,32 @@ module StandardId
|
|
|
53
58
|
end
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
def validate_social_link!(identifier, provider)
|
|
62
|
+
strategy = StandardId.config.social.link_strategy
|
|
63
|
+
|
|
64
|
+
unless VALID_LINK_STRATEGIES.include?(strategy)
|
|
65
|
+
raise ArgumentError, "Invalid social.link_strategy: #{strategy.inspect}. " \
|
|
66
|
+
"Must be one of: #{VALID_LINK_STRATEGIES.map(&:inspect).join(', ')}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return if strategy == :trust_provider
|
|
70
|
+
# nil provider means the identifier predates provider tracking — allow
|
|
71
|
+
# through since we can't retroactively determine its origin.
|
|
72
|
+
return if identifier.provider.nil?
|
|
73
|
+
return if identifier.provider == provider.provider_name
|
|
74
|
+
return if account_has_social_identifier_from?(identifier.account, provider)
|
|
75
|
+
|
|
76
|
+
emit_social_link_blocked(identifier, provider)
|
|
77
|
+
raise StandardId::SocialLinkError.new(
|
|
78
|
+
email: identifier.value,
|
|
79
|
+
provider_name: provider.provider_name
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def account_has_social_identifier_from?(account, provider)
|
|
84
|
+
account.identifiers.where(type: StandardId::EmailIdentifier.sti_name, provider: provider.provider_name).exists?
|
|
85
|
+
end
|
|
86
|
+
|
|
56
87
|
def build_account_from_social(social_info)
|
|
57
88
|
emit_account_creating_from_social(social_info)
|
|
58
89
|
attrs = resolve_account_attributes(social_info)
|
|
@@ -123,6 +154,16 @@ module StandardId
|
|
|
123
154
|
)
|
|
124
155
|
end
|
|
125
156
|
|
|
157
|
+
def emit_social_link_blocked(identifier, provider)
|
|
158
|
+
StandardId::Events.publish(
|
|
159
|
+
StandardId::Events::SOCIAL_LINK_BLOCKED,
|
|
160
|
+
email: identifier.value,
|
|
161
|
+
provider: provider,
|
|
162
|
+
identifier: identifier,
|
|
163
|
+
account: identifier.account
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
126
167
|
def emit_social_account_linked(account, provider, identifier)
|
|
127
168
|
StandardId::Events.publish(
|
|
128
169
|
StandardId::Events::SOCIAL_ACCOUNT_LINKED,
|
|
@@ -107,7 +107,13 @@ module StandardId
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
def session_manager
|
|
110
|
-
@session_manager ||= StandardId::Web::SessionManager.new(
|
|
110
|
+
@session_manager ||= StandardId::Web::SessionManager.new(
|
|
111
|
+
token_manager,
|
|
112
|
+
request: request,
|
|
113
|
+
session: session,
|
|
114
|
+
cookies: cookies,
|
|
115
|
+
reset_session: -> { reset_session }
|
|
116
|
+
)
|
|
111
117
|
end
|
|
112
118
|
|
|
113
119
|
def token_manager
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module WebMechanismGate
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
class_methods do
|
|
6
|
+
# Declares which web mechanism this controller requires.
|
|
7
|
+
# If the mechanism is disabled via config, requests return 404.
|
|
8
|
+
#
|
|
9
|
+
# class SignupController < BaseController
|
|
10
|
+
# requires_web_mechanism :signup
|
|
11
|
+
# end
|
|
12
|
+
def requires_web_mechanism(mechanism_name)
|
|
13
|
+
before_action -> { enforce_web_mechanism!(mechanism_name) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def enforce_web_mechanism!(mechanism_name)
|
|
20
|
+
unless StandardId.config.web.respond_to?(mechanism_name)
|
|
21
|
+
raise ArgumentError, "Unknown web mechanism: #{mechanism_name.inspect}. " \
|
|
22
|
+
"Valid mechanisms: #{StandardId.config.web.class.instance_methods(false).grep_v(/=/).sort.join(', ')}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
head :not_found unless StandardId.config.web.public_send(mechanism_name)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -4,10 +4,12 @@ module StandardId
|
|
|
4
4
|
module Callback
|
|
5
5
|
class ProvidersController < StandardId::Web::BaseController
|
|
6
6
|
public_controller
|
|
7
|
+
requires_web_mechanism :social_login
|
|
7
8
|
|
|
8
9
|
include StandardId::WebAuthentication
|
|
9
10
|
include StandardId::SocialAuthentication
|
|
10
11
|
include StandardId::Web::SocialLoginParams
|
|
12
|
+
include StandardId::LifecycleHooks
|
|
11
13
|
|
|
12
14
|
# Social callbacks must be accessible without an existing browser session
|
|
13
15
|
# because they create/sign-in the session upon successful callback.
|
|
@@ -28,21 +30,35 @@ module StandardId
|
|
|
28
30
|
provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
|
|
29
31
|
social_info = provider_response[:user_info]
|
|
30
32
|
provider_tokens = provider_response[:tokens]
|
|
31
|
-
|
|
33
|
+
begin
|
|
34
|
+
account = find_or_create_account_from_social(social_info)
|
|
35
|
+
rescue ActiveRecord::RecordNotUnique
|
|
36
|
+
# Race condition: concurrent request created the account first — retry to find it
|
|
37
|
+
account = find_or_create_account_from_social(social_info)
|
|
38
|
+
end
|
|
39
|
+
newly_created = account.previously_new_record?
|
|
32
40
|
session_manager.sign_in_account(account)
|
|
33
41
|
|
|
42
|
+
provider_name = provider.provider_name
|
|
43
|
+
invoke_after_account_created(account, { mechanism: "social", provider: provider_name }) if newly_created
|
|
44
|
+
|
|
34
45
|
run_social_callback(
|
|
35
|
-
provider:
|
|
46
|
+
provider: provider_name,
|
|
36
47
|
social_info: social_info,
|
|
37
48
|
provider_tokens: provider_tokens,
|
|
38
49
|
account: account,
|
|
39
50
|
original_request_params: state_data
|
|
40
51
|
)
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
context = { connection: "social", provider: provider_name }
|
|
54
|
+
redirect_override = invoke_after_sign_in(account, context)
|
|
55
|
+
|
|
56
|
+
destination = redirect_override || state_data["redirect_uri"]
|
|
57
|
+
redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
|
|
44
58
|
redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
|
|
45
59
|
redirect_to destination, redirect_options
|
|
60
|
+
rescue StandardId::AuthenticationDenied => e
|
|
61
|
+
handle_authentication_denied(e, account: account, newly_created: newly_created)
|
|
46
62
|
rescue StandardId::OAuthError => e
|
|
47
63
|
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
|
|
48
64
|
end
|
|
@@ -4,6 +4,7 @@ module StandardId
|
|
|
4
4
|
include StandardId::ControllerPolicy
|
|
5
5
|
include StandardId::WebAuthentication
|
|
6
6
|
include StandardId::SetCurrentRequestDetails
|
|
7
|
+
include StandardId::WebMechanismGate
|
|
7
8
|
|
|
8
9
|
include StandardId::WebEngine.routes.url_helpers
|
|
9
10
|
helper StandardId::WebEngine.routes.url_helpers
|
|
@@ -6,6 +6,7 @@ module StandardId
|
|
|
6
6
|
include StandardId::InertiaRendering
|
|
7
7
|
include StandardId::Web::SocialLoginParams
|
|
8
8
|
include StandardId::PasswordlessStrategy
|
|
9
|
+
include StandardId::LifecycleHooks
|
|
9
10
|
|
|
10
11
|
layout "public"
|
|
11
12
|
|
|
@@ -32,16 +33,23 @@ module StandardId
|
|
|
32
33
|
private
|
|
33
34
|
|
|
34
35
|
def passwordless_enabled?
|
|
35
|
-
StandardId.config.
|
|
36
|
+
StandardId.config.web.passwordless_login
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def handle_password_login
|
|
40
|
+
return head(:not_found) unless StandardId.config.web.password_login
|
|
41
|
+
|
|
39
42
|
if sign_in_account(login_params)
|
|
40
|
-
|
|
43
|
+
context = { connection: "password", provider: nil }
|
|
44
|
+
redirect_override = invoke_after_sign_in(current_account, context)
|
|
45
|
+
destination = redirect_override || params[:redirect_uri] || after_authentication_url
|
|
46
|
+
redirect_to destination, status: :see_other, notice: "Successfully signed in"
|
|
41
47
|
else
|
|
42
48
|
flash.now[:alert] = "Invalid email or password"
|
|
43
49
|
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
44
50
|
end
|
|
51
|
+
rescue StandardId::AuthenticationDenied => e
|
|
52
|
+
handle_authentication_denied(e)
|
|
45
53
|
end
|
|
46
54
|
|
|
47
55
|
def handle_passwordless_login
|
|
@@ -2,14 +2,14 @@ module StandardId
|
|
|
2
2
|
module Web
|
|
3
3
|
class LoginVerifyController < BaseController
|
|
4
4
|
public_controller
|
|
5
|
+
requires_web_mechanism :passwordless_login
|
|
5
6
|
|
|
6
7
|
include StandardId::InertiaRendering
|
|
8
|
+
include StandardId::LifecycleHooks
|
|
7
9
|
|
|
8
10
|
layout "public"
|
|
9
11
|
|
|
10
12
|
skip_before_action :require_browser_session!, only: [:show, :update]
|
|
11
|
-
|
|
12
|
-
before_action :ensure_passwordless_enabled!
|
|
13
13
|
before_action :redirect_if_authenticated, only: [:show]
|
|
14
14
|
before_action :require_otp_payload!
|
|
15
15
|
|
|
@@ -39,23 +39,28 @@ module StandardId
|
|
|
39
39
|
return
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
account = result.account
|
|
43
|
+
newly_created = account.previously_new_record?
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
session_manager.sign_in_account(account)
|
|
46
|
+
emit_authentication_succeeded(account)
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
end
|
|
48
|
+
invoke_after_account_created(account, { mechanism: "passwordless", provider: nil }) if newly_created
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
context = { connection: @otp_data[:connection], provider: nil }
|
|
51
|
+
redirect_override = invoke_after_sign_in(account, context)
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
return if StandardId.config.passwordless.enabled
|
|
53
|
+
session.delete(:standard_id_otp_payload)
|
|
54
54
|
|
|
55
|
+
destination = redirect_override || after_authentication_url
|
|
56
|
+
redirect_to destination, status: :see_other, notice: "Successfully signed in"
|
|
57
|
+
rescue StandardId::AuthenticationDenied => e
|
|
55
58
|
session.delete(:standard_id_otp_payload)
|
|
56
|
-
|
|
59
|
+
handle_authentication_denied(e, account: account, newly_created: newly_created)
|
|
57
60
|
end
|
|
58
61
|
|
|
62
|
+
private
|
|
63
|
+
|
|
59
64
|
def redirect_if_authenticated
|
|
60
65
|
redirect_to after_authentication_url, status: :see_other if authenticated?
|
|
61
66
|
end
|
|
@@ -2,8 +2,10 @@ module StandardId
|
|
|
2
2
|
module Web
|
|
3
3
|
class SignupController < BaseController
|
|
4
4
|
public_controller
|
|
5
|
+
requires_web_mechanism :signup
|
|
5
6
|
|
|
6
7
|
include StandardId::InertiaRendering
|
|
8
|
+
include StandardId::LifecycleHooks
|
|
7
9
|
|
|
8
10
|
layout "public"
|
|
9
11
|
|
|
@@ -38,14 +40,20 @@ module StandardId
|
|
|
38
40
|
|
|
39
41
|
if form.submit
|
|
40
42
|
session_manager.sign_in_account(form.account)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
|
|
44
|
+
|
|
45
|
+
context = { connection: "password", provider: nil }
|
|
46
|
+
redirect_override = invoke_after_sign_in(form.account, context)
|
|
47
|
+
destination = redirect_override || params[:redirect_uri] || after_authentication_url
|
|
48
|
+
redirect_to destination, notice: "Account created successfully"
|
|
43
49
|
else
|
|
44
50
|
@redirect_uri = params[:redirect_uri] || after_authentication_url
|
|
45
51
|
@connection = params[:connection]
|
|
46
52
|
flash.now[:alert] = form.errors.full_messages.join(", ")
|
|
47
53
|
render_with_inertia action: :show, props: auth_page_props(errors: form.errors.to_hash), status: :unprocessable_content
|
|
48
54
|
end
|
|
55
|
+
rescue StandardId::AuthenticationDenied => e
|
|
56
|
+
handle_authentication_denied(e, account: form.account, newly_created: form.account&.previously_new_record?)
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def social_signup_url
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class CleanupExpiredRefreshTokensJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
# Delete refresh tokens that expired or were revoked more than
|
|
6
|
+
# `grace_period_seconds` ago.
|
|
7
|
+
# Accepts integer seconds for reliable ActiveJob serialization across all queue adapters.
|
|
8
|
+
def perform(grace_period_seconds: 7.days.to_i)
|
|
9
|
+
cutoff = grace_period_seconds.seconds.ago
|
|
10
|
+
deleted = StandardId::RefreshToken
|
|
11
|
+
.where("expires_at < :cutoff OR revoked_at < :cutoff", cutoff: cutoff)
|
|
12
|
+
.delete_all
|
|
13
|
+
Rails.logger.info("[StandardId] Cleaned up #{deleted} expired/revoked refresh tokens older than #{cutoff}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -6,6 +6,7 @@ module StandardId
|
|
|
6
6
|
has_many :identifiers, class_name: "StandardId::Identifier", dependent: :restrict_with_exception
|
|
7
7
|
has_many :credentials, class_name: "StandardId::Credential", through: :identifiers, source: :credentials, dependent: :restrict_with_exception
|
|
8
8
|
has_many :sessions, class_name: "StandardId::Session", dependent: :restrict_with_exception
|
|
9
|
+
has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :restrict_with_exception
|
|
9
10
|
has_many :client_applications, class_name: "StandardId::ClientApplication", as: :owner, dependent: :restrict_with_exception
|
|
10
11
|
|
|
11
12
|
accepts_nested_attributes_for :identifiers
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class RefreshToken < ApplicationRecord
|
|
3
|
+
self.table_name = "standard_id_refresh_tokens"
|
|
4
|
+
|
|
5
|
+
belongs_to :account, class_name: StandardId.config.account_class_name
|
|
6
|
+
belongs_to :session, class_name: "StandardId::Session", optional: true
|
|
7
|
+
belongs_to :previous_token, class_name: "StandardId::RefreshToken", optional: true
|
|
8
|
+
|
|
9
|
+
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
10
|
+
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
11
|
+
scope :revoked, -> { where.not(revoked_at: nil) }
|
|
12
|
+
|
|
13
|
+
validates :token_digest, presence: true, uniqueness: true
|
|
14
|
+
validates :expires_at, presence: true
|
|
15
|
+
|
|
16
|
+
def self.digest_for(jti)
|
|
17
|
+
Digest::SHA256.hexdigest(jti)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.find_by_jti(jti)
|
|
21
|
+
find_by(token_digest: digest_for(jti))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def active?
|
|
25
|
+
!revoked? && !expired?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def expired?
|
|
29
|
+
expires_at <= Time.current
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def revoked?
|
|
33
|
+
revoked_at.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def revoke!
|
|
37
|
+
update!(revoked_at: Time.current) unless revoked?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Revoke this token and all tokens in the same family chain.
|
|
41
|
+
# A "family" is all tokens linked via previous_token_id.
|
|
42
|
+
# Only revokes tokens that aren't already revoked, preserving historical
|
|
43
|
+
# revoked_at timestamps for audit purposes.
|
|
44
|
+
def revoke_family!
|
|
45
|
+
family_tokens.where(revoked_at: nil).update_all(revoked_at: Time.current)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Max depth guard to prevent unbounded traversal in case of
|
|
49
|
+
# corrupted data or extremely long-lived token chains.
|
|
50
|
+
MAX_FAMILY_DEPTH = 50
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Find the root of this token's family and return all descendants.
|
|
55
|
+
# Uses iterative queries (one per generation) bounded by MAX_FAMILY_DEPTH.
|
|
56
|
+
# For typical token chain lengths (<10) this is fine; a recursive CTE
|
|
57
|
+
# would collapse to a single query if performance becomes a concern.
|
|
58
|
+
def family_tokens
|
|
59
|
+
root = self
|
|
60
|
+
depth = 0
|
|
61
|
+
while root.previous_token.present? && depth < MAX_FAMILY_DEPTH
|
|
62
|
+
root = root.previous_token
|
|
63
|
+
depth += 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
self.class.where(id: collect_family_ids(root.id))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def collect_family_ids(root_id)
|
|
70
|
+
ids = [root_id]
|
|
71
|
+
current_ids = [root_id]
|
|
72
|
+
depth = 0
|
|
73
|
+
|
|
74
|
+
while depth < MAX_FAMILY_DEPTH
|
|
75
|
+
next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
|
|
76
|
+
break if next_ids.empty?
|
|
77
|
+
|
|
78
|
+
ids.concat(next_ids)
|
|
79
|
+
current_ids = next_ids
|
|
80
|
+
depth += 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
ids
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -5,6 +5,9 @@ module StandardId
|
|
|
5
5
|
self.table_name = "standard_id_sessions"
|
|
6
6
|
|
|
7
7
|
belongs_to :account, class_name: StandardId.config.account_class_name
|
|
8
|
+
has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :nullify
|
|
9
|
+
|
|
10
|
+
before_destroy :revoke_active_refresh_tokens
|
|
8
11
|
|
|
9
12
|
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
10
13
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
@@ -36,6 +39,9 @@ module StandardId
|
|
|
36
39
|
def revoke!(reason: nil)
|
|
37
40
|
@reason = reason
|
|
38
41
|
update!(revoked_at: Time.current)
|
|
42
|
+
# Cascade revocation to refresh tokens. Uses update_all for efficiency;
|
|
43
|
+
# intentionally skips updated_at since revocation is tracked via revoked_at.
|
|
44
|
+
refresh_tokens.active.update_all(revoked_at: Time.current)
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
private
|
|
@@ -52,6 +58,12 @@ module StandardId
|
|
|
52
58
|
self.lookup_hash = Digest::SHA256.hexdigest("#{token}:#{Rails.configuration.secret_key_base}")
|
|
53
59
|
end
|
|
54
60
|
|
|
61
|
+
# Revoke any still-active refresh tokens before the session row is deleted,
|
|
62
|
+
# so tokens don't become orphaned but usable.
|
|
63
|
+
def revoke_active_refresh_tokens
|
|
64
|
+
refresh_tokens.active.update_all(revoked_at: Time.current)
|
|
65
|
+
end
|
|
66
|
+
|
|
55
67
|
def just_revoked?
|
|
56
68
|
saved_change_to_revoked_at? && revoked?
|
|
57
69
|
end
|
data/config/brakeman.ignore
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"ignored_warnings": [
|
|
3
|
-
{
|
|
4
|
-
"fingerprint": "24fc02735a2ad863d6bf1171a4a329b208e9e7c41841fa0149d8e6878d4ce299",
|
|
5
|
-
"note": "Auth engine intentionally redirects to params[:redirect_uri] after signup for OAuth/post-auth flow"
|
|
6
|
-
},
|
|
7
3
|
{
|
|
8
4
|
"fingerprint": "6b35e9906d62a9b9cd0dff9cf53924d40e74bc4f96cfccf27e67e93551113243",
|
|
9
5
|
"note": "Auth engine intentionally redirects to params[:redirect_uri] after logout for OAuth/post-auth flow"
|
|
@@ -16,15 +12,19 @@
|
|
|
16
12
|
"fingerprint": "bdbc72619da2ba771b1185ccf16acce801066689bf51adf116eab8c8714b39af",
|
|
17
13
|
"note": "HEAD vs GET distinction is inconsequential here; storing return URL on GET-only is safe"
|
|
18
14
|
},
|
|
19
|
-
{
|
|
20
|
-
"fingerprint": "16bd6ec7c3fa130eb80c15fc90c87f9859d89b37258807bfaffe4101366611a6",
|
|
21
|
-
"note": "Auth engine intentionally redirects to params[:redirect_uri] after login for OAuth/post-auth flow"
|
|
22
|
-
},
|
|
23
15
|
{
|
|
24
16
|
"fingerprint": "e4f96cb212c73c3165c3db6eaa6368c29d362b61264f034e80c9fa6705d72e5b",
|
|
25
17
|
"note": "Auth engine intentionally redirects to params[:redirect_uri] when user is not authenticated"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"fingerprint": "68be03a57d3ef2cfb68582fc78ac2eb6b96aaa0a9897a9a975c24b889fdbb2aa",
|
|
21
|
+
"note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"fingerprint": "277cf277d1c94f46d0abaeba9c51312d1d17e6f62c2e8d457dda47a6aad422aa",
|
|
25
|
+
"note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
|
|
26
26
|
}
|
|
27
27
|
],
|
|
28
|
-
"updated": "2026-
|
|
29
|
-
"brakeman_version": "8.0.
|
|
28
|
+
"updated": "2026-03-21",
|
|
29
|
+
"brakeman_version": "8.0.4"
|
|
30
30
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Adds provider tracking to identifiers for social login link validation.
|
|
2
|
+
#
|
|
3
|
+
# After running this migration, existing identifiers will have provider=NULL.
|
|
4
|
+
# The strict link strategy treats NULL provider as "pre-migration" and allows
|
|
5
|
+
# linking, so existing users are not blocked. However, this means pre-migration
|
|
6
|
+
# accounts are not fully protected by the strict strategy until their provider
|
|
7
|
+
# is populated — either by logging in again via social, or by running a
|
|
8
|
+
# backfill (e.g. UPDATE standard_id_identifiers SET provider = 'email'
|
|
9
|
+
# WHERE provider IS NULL AND type = 'StandardId::EmailIdentifier').
|
|
10
|
+
class AddProviderToStandardIdIdentifiers < ActiveRecord::Migration[8.0]
|
|
11
|
+
def change
|
|
12
|
+
add_column :standard_id_identifiers, :provider, :string
|
|
13
|
+
add_index :standard_id_identifiers, [:account_id, :provider]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreateStandardIdRefreshTokens < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :standard_id_refresh_tokens, id: primary_key_type do |t|
|
|
4
|
+
t.references :account, type: primary_key_type, null: false, foreign_key: true, index: true
|
|
5
|
+
t.references :session, type: primary_key_type, null: true, foreign_key: { to_table: :standard_id_sessions }, index: true
|
|
6
|
+
|
|
7
|
+
t.string :token_digest, null: false, index: { unique: true }
|
|
8
|
+
t.datetime :expires_at, null: false
|
|
9
|
+
t.datetime :revoked_at
|
|
10
|
+
|
|
11
|
+
t.references :previous_token, type: primary_key_type, null: true, foreign_key: { to_table: :standard_id_refresh_tokens }, index: true
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
|
|
15
|
+
t.index [:account_id, :revoked_at], name: "idx_on_account_id_revoked_at_refresh_tokens"
|
|
16
|
+
t.index [:session_id, :revoked_at], name: "idx_on_session_id_revoked_at_refresh_tokens"
|
|
17
|
+
t.index [:expires_at, :revoked_at], name: "idx_on_expires_at_revoked_at_refresh_tokens"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -114,6 +114,7 @@ StandardId.configure do |c|
|
|
|
114
114
|
# c.social.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
115
115
|
# c.social.allowed_redirect_url_prefixes = ["sidekicklabs://"]
|
|
116
116
|
# c.social.available_scopes = ["profile", "email", "offline_access"]
|
|
117
|
+
# c.social.link_strategy = :strict # :strict (default) or :trust_provider
|
|
117
118
|
# c.social.social_account_attributes = ->(social_info:, provider:) {
|
|
118
119
|
# {
|
|
119
120
|
# email: social_info[:email],
|
|
@@ -21,6 +21,20 @@ StandardConfig.schema.draw do
|
|
|
21
21
|
# Callable (lambda/proc) that returns a Hash of extra Sentry user context fields.
|
|
22
22
|
# Receives (account, session) where session may be nil. Non-callable values are ignored.
|
|
23
23
|
field :sentry_context, type: :any, default: nil
|
|
24
|
+
|
|
25
|
+
# Post-authentication lifecycle hooks (synchronous, WebEngine only)
|
|
26
|
+
#
|
|
27
|
+
# after_sign_in: Called after successful sign-in, before redirect.
|
|
28
|
+
# Receives: (account, request, context)
|
|
29
|
+
# Context: { first_sign_in: bool, connection: "email"/"password"/"social", provider: nil/"google"/"apple" }
|
|
30
|
+
# Return: nil (default redirect) or a path string (override redirect)
|
|
31
|
+
# Raise StandardId::AuthenticationDenied.new("message") to reject sign-in.
|
|
32
|
+
field :after_sign_in, type: :any, default: nil
|
|
33
|
+
|
|
34
|
+
# after_account_created: Called after a new account is created via any mechanism.
|
|
35
|
+
# Receives: (account, request, context)
|
|
36
|
+
# Context: { mechanism: "passwordless"/"social"/"signup", provider: nil/"google"/"apple" }
|
|
37
|
+
field :after_account_created, type: :any, default: nil
|
|
24
38
|
end
|
|
25
39
|
|
|
26
40
|
scope :events do
|
|
@@ -28,6 +42,8 @@ StandardConfig.schema.draw do
|
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
scope :passwordless do
|
|
45
|
+
# Deprecated: use web.passwordless_login to control WebEngine passwordless login.
|
|
46
|
+
# Retained for backwards compatibility with consuming apps that set this field.
|
|
31
47
|
field :enabled, type: :boolean, default: false
|
|
32
48
|
field :connection, type: :string, default: "email"
|
|
33
49
|
field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
|
|
@@ -80,5 +96,17 @@ StandardConfig.schema.draw do
|
|
|
80
96
|
field :social_account_attributes, type: :any, default: nil
|
|
81
97
|
field :allowed_redirect_url_prefixes, type: :array, default: []
|
|
82
98
|
field :available_scopes, type: :array, default: -> { [] }
|
|
99
|
+
field :link_strategy, type: :symbol, default: :strict
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
scope :web do
|
|
103
|
+
field :password_login, type: :boolean, default: true
|
|
104
|
+
field :signup, type: :boolean, default: true
|
|
105
|
+
field :passwordless_login, type: :boolean, default: false
|
|
106
|
+
field :social_login, type: :boolean, default: true
|
|
107
|
+
field :password_reset, type: :boolean, default: true
|
|
108
|
+
field :email_verification, type: :boolean, default: true
|
|
109
|
+
field :phone_verification, type: :boolean, default: true
|
|
110
|
+
field :sessions_management, type: :boolean, default: true
|
|
83
111
|
end
|
|
84
112
|
end
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -2,6 +2,20 @@ module StandardId
|
|
|
2
2
|
class Engine < ::Rails::Engine
|
|
3
3
|
isolate_namespace StandardId
|
|
4
4
|
|
|
5
|
+
initializer "standard_id.filter_parameters" do |app|
|
|
6
|
+
app.config.filter_parameters += %i[
|
|
7
|
+
code_verifier
|
|
8
|
+
code_challenge
|
|
9
|
+
client_secret
|
|
10
|
+
id_token
|
|
11
|
+
refresh_token
|
|
12
|
+
access_token
|
|
13
|
+
state
|
|
14
|
+
nonce
|
|
15
|
+
authorization_code
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
|
|
5
19
|
config.after_initialize do
|
|
6
20
|
if StandardId.config.events.enable_logging
|
|
7
21
|
StandardId::Events::Subscribers::LoggingSubscriber.attach
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -70,6 +70,27 @@ module StandardId
|
|
|
70
70
|
def oauth_error_code = :unsupported_response_type
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Lifecycle hook errors
|
|
74
|
+
class AuthenticationDenied < StandardError; end
|
|
75
|
+
|
|
76
|
+
# Social login errors
|
|
77
|
+
# NOTE: email and provider_name are exposed as reader attributes for host
|
|
78
|
+
# apps to build custom error responses. If you report exceptions to an
|
|
79
|
+
# error tracker (Sentry, etc.), be aware these attributes contain PII.
|
|
80
|
+
class SocialLinkError < OAuthError
|
|
81
|
+
attr_reader :email, :provider_name
|
|
82
|
+
|
|
83
|
+
def initialize(email:, provider_name:)
|
|
84
|
+
@email = email
|
|
85
|
+
@provider_name = provider_name
|
|
86
|
+
super("This email is already associated with an account. Please sign in first to link this provider.")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Uses standard OAuth :access_denied code since account_link_required is non-standard
|
|
90
|
+
def oauth_error_code = :access_denied
|
|
91
|
+
def http_status = :forbidden
|
|
92
|
+
end
|
|
93
|
+
|
|
73
94
|
# Audience verification errors
|
|
74
95
|
class InvalidAudienceError < StandardError
|
|
75
96
|
attr_reader :required, :actual
|
|
@@ -40,6 +40,7 @@ module StandardId
|
|
|
40
40
|
OAUTH_TOKEN_REFRESHED = "oauth.token.refreshed"
|
|
41
41
|
OAUTH_CODE_CONSUMED = "oauth.code.consumed"
|
|
42
42
|
OAUTH_TOKEN_REVOKED = "oauth.token.revoked"
|
|
43
|
+
OAUTH_REFRESH_TOKEN_REUSE_DETECTED = "oauth.refresh_token.reuse_detected"
|
|
43
44
|
|
|
44
45
|
PASSWORDLESS_CODE_REQUESTED = "passwordless.code.requested"
|
|
45
46
|
PASSWORDLESS_CODE_GENERATED = "passwordless.code.generated"
|
|
@@ -53,6 +54,7 @@ module StandardId
|
|
|
53
54
|
SOCIAL_USER_INFO_FETCHED = "social.user_info.fetched"
|
|
54
55
|
SOCIAL_ACCOUNT_CREATED = "social.account.created"
|
|
55
56
|
SOCIAL_ACCOUNT_LINKED = "social.account.linked"
|
|
57
|
+
SOCIAL_LINK_BLOCKED = "social.link.blocked"
|
|
56
58
|
SOCIAL_AUTH_COMPLETED = "social.auth.completed"
|
|
57
59
|
|
|
58
60
|
CREDENTIAL_PASSWORD_CREATED = "credential.password.created"
|
|
@@ -110,7 +112,8 @@ module StandardId
|
|
|
110
112
|
OAUTH_TOKEN_ISSUED,
|
|
111
113
|
OAUTH_TOKEN_REFRESHED,
|
|
112
114
|
OAUTH_CODE_CONSUMED,
|
|
113
|
-
OAUTH_TOKEN_REVOKED
|
|
115
|
+
OAUTH_TOKEN_REVOKED,
|
|
116
|
+
OAUTH_REFRESH_TOKEN_REUSE_DETECTED
|
|
114
117
|
].freeze
|
|
115
118
|
|
|
116
119
|
PASSWORDLESS_EVENTS = [
|
|
@@ -128,6 +131,7 @@ module StandardId
|
|
|
128
131
|
SOCIAL_USER_INFO_FETCHED,
|
|
129
132
|
SOCIAL_ACCOUNT_CREATED,
|
|
130
133
|
SOCIAL_ACCOUNT_LINKED,
|
|
134
|
+
SOCIAL_LINK_BLOCKED,
|
|
131
135
|
SOCIAL_AUTH_COMPLETED
|
|
132
136
|
].freeze
|
|
133
137
|
|
|
@@ -167,6 +171,7 @@ module StandardId
|
|
|
167
171
|
OAUTH_TOKEN_ISSUED,
|
|
168
172
|
OAUTH_TOKEN_REFRESHED,
|
|
169
173
|
OAUTH_TOKEN_REVOKED,
|
|
174
|
+
OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
|
|
170
175
|
# Passwordless
|
|
171
176
|
PASSWORDLESS_CODE_FAILED,
|
|
172
177
|
PASSWORDLESS_ACCOUNT_CREATED,
|
|
@@ -180,7 +185,8 @@ module StandardId
|
|
|
180
185
|
CREDENTIAL_CLIENT_SECRET_REVOKED,
|
|
181
186
|
# Social
|
|
182
187
|
SOCIAL_ACCOUNT_CREATED,
|
|
183
|
-
SOCIAL_ACCOUNT_LINKED
|
|
188
|
+
SOCIAL_ACCOUNT_LINKED,
|
|
189
|
+
SOCIAL_LINK_BLOCKED
|
|
184
190
|
].freeze
|
|
185
191
|
|
|
186
192
|
ALL_EVENTS = (
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
require "ipaddr"
|
|
1
2
|
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "resolv"
|
|
2
5
|
require "uri"
|
|
3
6
|
|
|
4
7
|
module StandardId
|
|
@@ -6,27 +9,70 @@ module StandardId
|
|
|
6
9
|
OPEN_TIMEOUT = 5
|
|
7
10
|
READ_TIMEOUT = 10
|
|
8
11
|
|
|
12
|
+
class SsrfError < StandardError; end
|
|
13
|
+
|
|
14
|
+
BLOCKED_IP_RANGES = [
|
|
15
|
+
IPAddr.new("10.0.0.0/8"),
|
|
16
|
+
IPAddr.new("172.16.0.0/12"),
|
|
17
|
+
IPAddr.new("192.168.0.0/16"),
|
|
18
|
+
IPAddr.new("127.0.0.0/8"),
|
|
19
|
+
IPAddr.new("169.254.0.0/16"),
|
|
20
|
+
IPAddr.new("0.0.0.0/8"),
|
|
21
|
+
IPAddr.new("::1/128"),
|
|
22
|
+
IPAddr.new("fc00::/7"),
|
|
23
|
+
IPAddr.new("fe80::/10")
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
9
26
|
class << self
|
|
10
27
|
def post_form(endpoint, params)
|
|
11
|
-
uri =
|
|
28
|
+
uri, resolved_ip = validate_url!(endpoint)
|
|
12
29
|
request = Net::HTTP::Post.new(uri)
|
|
13
30
|
request.set_form_data(params)
|
|
14
|
-
start_connection(uri) { |http| http.request(request) }
|
|
31
|
+
start_connection(uri, resolved_ip:) { |http| http.request(request) }
|
|
15
32
|
end
|
|
16
33
|
|
|
17
34
|
def get_with_bearer(endpoint, access_token)
|
|
18
|
-
uri =
|
|
35
|
+
uri, resolved_ip = validate_url!(endpoint)
|
|
19
36
|
request = Net::HTTP::Get.new(uri)
|
|
20
37
|
request["Authorization"] = "Bearer #{access_token}"
|
|
21
|
-
start_connection(uri) { |http| http.request(request) }
|
|
38
|
+
start_connection(uri, resolved_ip:) { |http| http.request(request) }
|
|
22
39
|
end
|
|
23
40
|
|
|
24
41
|
private
|
|
25
42
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
def validate_url!(url)
|
|
44
|
+
uri = URI.parse(url.to_s)
|
|
45
|
+
raise SsrfError, "Only http and https schemes are allowed" unless %w[http https].include?(uri.scheme)
|
|
46
|
+
raise SsrfError, "Invalid URL: missing host" if uri.host.nil? || uri.host.empty?
|
|
47
|
+
|
|
48
|
+
addresses = Resolv.getaddresses(uri.host)
|
|
49
|
+
raise SsrfError, "Could not resolve host" if addresses.empty?
|
|
50
|
+
|
|
51
|
+
addresses.each do |addr|
|
|
52
|
+
ip = IPAddr.new(addr)
|
|
53
|
+
if BLOCKED_IP_RANGES.any? { |range| range.include?(ip) }
|
|
54
|
+
raise SsrfError, "Requests to private/internal addresses are not allowed"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Return resolved IP to pin connection and prevent DNS rebinding
|
|
59
|
+
[uri, addresses.first]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def start_connection(uri, resolved_ip: nil, &block)
|
|
63
|
+
host = resolved_ip || uri.host
|
|
64
|
+
options = {
|
|
65
|
+
use_ssl: uri.scheme == "https",
|
|
66
|
+
open_timeout: OPEN_TIMEOUT,
|
|
67
|
+
read_timeout: READ_TIMEOUT
|
|
68
|
+
}
|
|
69
|
+
options[:verify_mode] = OpenSSL::SSL::VERIFY_PEER if options[:use_ssl]
|
|
70
|
+
|
|
71
|
+
Net::HTTP.start(host, uri.port, **options) do |http|
|
|
72
|
+
# Set Host header for virtual hosting when connecting to resolved IP
|
|
73
|
+
http.instance_variable_set(:@address, uri.host) if resolved_ip
|
|
74
|
+
yield http
|
|
75
|
+
end
|
|
30
76
|
end
|
|
31
77
|
end
|
|
32
78
|
end
|
|
@@ -122,8 +122,12 @@ module StandardId
|
|
|
122
122
|
@jwks_ref.set(nil)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
def self.encode(payload, expires_in:
|
|
126
|
-
payload[:exp] =
|
|
125
|
+
def self.encode(payload, expires_in: nil, expires_at: nil)
|
|
126
|
+
payload[:exp] = if expires_at
|
|
127
|
+
expires_at.to_i
|
|
128
|
+
else
|
|
129
|
+
(expires_in || 1.hour).from_now.to_i
|
|
130
|
+
end
|
|
127
131
|
payload[:iat] = Time.current.to_i
|
|
128
132
|
payload[:iss] ||= StandardId.config.issuer if StandardId.config.issuer.present?
|
|
129
133
|
|
|
@@ -14,11 +14,66 @@ module StandardId
|
|
|
14
14
|
raise StandardId::InvalidGrantError, "Refresh token was not issued to this client"
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
validate_refresh_token_record!
|
|
17
18
|
validate_scope_narrowing!
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
23
|
+
def validate_refresh_token_record!
|
|
24
|
+
jti = @refresh_payload[:jti]
|
|
25
|
+
# Legacy tokens minted before jti tracking was added cannot be looked
|
|
26
|
+
# up or revoked through the RefreshToken model. This shim can be removed
|
|
27
|
+
# once all pre-jti tokens have expired (refresh_token_lifetime after deploy).
|
|
28
|
+
return if jti.blank?
|
|
29
|
+
|
|
30
|
+
@current_refresh_token_record = StandardId::RefreshToken.find_by_jti(jti)
|
|
31
|
+
|
|
32
|
+
unless @current_refresh_token_record
|
|
33
|
+
raise StandardId::InvalidGrantError, "Refresh token not found"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if @current_refresh_token_record.revoked?
|
|
37
|
+
# Reuse detected: this token was already rotated. Revoke entire family.
|
|
38
|
+
@current_refresh_token_record.revoke_family!
|
|
39
|
+
StandardId::Events.publish(
|
|
40
|
+
StandardId::Events::OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
|
|
41
|
+
account_id: @refresh_payload[:sub],
|
|
42
|
+
client_id: @refresh_payload[:client_id],
|
|
43
|
+
refresh_token_id: @current_refresh_token_record.id
|
|
44
|
+
)
|
|
45
|
+
raise StandardId::InvalidGrantError, "Refresh token reuse detected"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
unless @current_refresh_token_record.active?
|
|
49
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Atomically revoke the current token as part of rotation.
|
|
53
|
+
# Uses a conditional UPDATE to prevent TOCTOU race conditions — only one
|
|
54
|
+
# concurrent request can successfully revoke and proceed.
|
|
55
|
+
rows = StandardId::RefreshToken
|
|
56
|
+
.where(id: @current_refresh_token_record.id, revoked_at: nil)
|
|
57
|
+
.update_all(revoked_at: Time.current)
|
|
58
|
+
|
|
59
|
+
if rows == 0
|
|
60
|
+
# A concurrent request revoked the token between the revoked? check
|
|
61
|
+
# and the UPDATE. Re-load to determine whether this is a reuse scenario.
|
|
62
|
+
@current_refresh_token_record.reload
|
|
63
|
+
if @current_refresh_token_record.revoked?
|
|
64
|
+
@current_refresh_token_record.revoke_family!
|
|
65
|
+
StandardId::Events.publish(
|
|
66
|
+
StandardId::Events::OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
|
|
67
|
+
account_id: @refresh_payload[:sub],
|
|
68
|
+
client_id: @refresh_payload[:client_id],
|
|
69
|
+
refresh_token_id: @current_refresh_token_record.id
|
|
70
|
+
)
|
|
71
|
+
raise StandardId::InvalidGrantError, "Refresh token reuse detected"
|
|
72
|
+
end
|
|
73
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
22
77
|
def subject_id
|
|
23
78
|
@refresh_payload[:sub]
|
|
24
79
|
end
|
|
@@ -46,6 +101,17 @@ module StandardId
|
|
|
46
101
|
@refresh_payload[:aud]
|
|
47
102
|
end
|
|
48
103
|
|
|
104
|
+
def refresh_token_session_id
|
|
105
|
+
@current_refresh_token_record&.session_id
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns the (now-revoked) token record so it can be linked as
|
|
109
|
+
# previous_token on the newly minted refresh token, maintaining the
|
|
110
|
+
# family chain for reuse detection.
|
|
111
|
+
def previous_refresh_token_record
|
|
112
|
+
@current_refresh_token_record
|
|
113
|
+
end
|
|
114
|
+
|
|
49
115
|
def validate_scope_narrowing!
|
|
50
116
|
return unless params[:scope].present?
|
|
51
117
|
|
|
@@ -74,14 +74,43 @@ module StandardId
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def generate_refresh_token
|
|
77
|
+
jti = SecureRandom.uuid
|
|
77
78
|
payload = {
|
|
78
79
|
sub: subject_id,
|
|
79
80
|
client_id: client_id,
|
|
80
81
|
scope: token_scope,
|
|
81
82
|
aud: audience,
|
|
82
|
-
grant_type: "refresh_token"
|
|
83
|
+
grant_type: "refresh_token",
|
|
84
|
+
jti: jti
|
|
83
85
|
}.compact
|
|
84
|
-
|
|
86
|
+
|
|
87
|
+
expiry = refresh_token_expiry
|
|
88
|
+
# Capture expires_at once so the JWT exp and DB record are consistent
|
|
89
|
+
expires_at = expiry.from_now
|
|
90
|
+
|
|
91
|
+
# Persist the DB record first so we never hand out a signed JWT
|
|
92
|
+
# that has no backing record (e.g. if the INSERT were to fail).
|
|
93
|
+
persist_refresh_token!(jti: jti, expires_at: expires_at)
|
|
94
|
+
|
|
95
|
+
StandardId::JwtService.encode(payload, expires_at: expires_at)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def persist_refresh_token!(jti:, expires_at:)
|
|
99
|
+
StandardId::RefreshToken.create!(
|
|
100
|
+
account_id: subject_id,
|
|
101
|
+
session_id: refresh_token_session_id,
|
|
102
|
+
token_digest: StandardId::RefreshToken.digest_for(jti),
|
|
103
|
+
expires_at: expires_at,
|
|
104
|
+
previous_token: previous_refresh_token_record
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def refresh_token_session_id
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def previous_refresh_token_record
|
|
113
|
+
nil
|
|
85
114
|
end
|
|
86
115
|
|
|
87
116
|
def refresh_token_expiry
|
data/lib/standard_id/version.rb
CHANGED
|
@@ -3,11 +3,12 @@ module StandardId
|
|
|
3
3
|
class SessionManager
|
|
4
4
|
attr_reader :token_manager, :request, :session, :cookies
|
|
5
5
|
|
|
6
|
-
def initialize(token_manager, request:, session:, cookies:)
|
|
6
|
+
def initialize(token_manager, request:, session:, cookies:, reset_session: nil)
|
|
7
7
|
@token_manager = token_manager
|
|
8
8
|
@request = request
|
|
9
9
|
@session = session
|
|
10
10
|
@cookies = cookies
|
|
11
|
+
@reset_session = reset_session
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def current_session
|
|
@@ -20,6 +21,14 @@ module StandardId
|
|
|
20
21
|
|
|
21
22
|
def sign_in_account(account)
|
|
22
23
|
emit_session_creating(account, "browser")
|
|
24
|
+
|
|
25
|
+
# Prevent session fixation by resetting the Rails session before
|
|
26
|
+
# creating an authenticated session (Rails Security Guide §2.5).
|
|
27
|
+
# Preserve return_to URL across the reset so post-login redirect works.
|
|
28
|
+
return_to = session[:return_to_after_authenticating]
|
|
29
|
+
@reset_session&.call
|
|
30
|
+
session[:return_to_after_authenticating] = return_to if return_to
|
|
31
|
+
|
|
23
32
|
token_manager.create_browser_session(account).tap do |browser_session|
|
|
24
33
|
# Store in both session and encrypted cookie for backward compatibility
|
|
25
34
|
# Action Cable will use the encrypted cookie
|
|
@@ -91,6 +100,9 @@ module StandardId
|
|
|
91
100
|
password_credential = StandardId::PasswordCredential.find_by_token_for(:remember_me, cookies[:remember_token])
|
|
92
101
|
return if password_credential.blank?
|
|
93
102
|
|
|
103
|
+
# Prevent session fixation on returning-user remember-me flow
|
|
104
|
+
@reset_session&.call
|
|
105
|
+
|
|
94
106
|
token_manager.create_browser_session(password_credential.account, remember_me: true).tap do |browser_session|
|
|
95
107
|
# Store in both session and encrypted cookie for backward compatibility
|
|
96
108
|
session[:session_token] = browser_session.token
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -112,12 +112,14 @@ files:
|
|
|
112
112
|
- app/controllers/concerns/standard_id/controller_policy.rb
|
|
113
113
|
- app/controllers/concerns/standard_id/inertia_rendering.rb
|
|
114
114
|
- app/controllers/concerns/standard_id/inertia_support.rb
|
|
115
|
+
- app/controllers/concerns/standard_id/lifecycle_hooks.rb
|
|
115
116
|
- app/controllers/concerns/standard_id/passwordless_strategy.rb
|
|
116
117
|
- app/controllers/concerns/standard_id/sentry_context.rb
|
|
117
118
|
- app/controllers/concerns/standard_id/set_current_request_details.rb
|
|
118
119
|
- app/controllers/concerns/standard_id/social_authentication.rb
|
|
119
120
|
- app/controllers/concerns/standard_id/web/social_login_params.rb
|
|
120
121
|
- app/controllers/concerns/standard_id/web_authentication.rb
|
|
122
|
+
- app/controllers/concerns/standard_id/web_mechanism_gate.rb
|
|
121
123
|
- app/controllers/standard_id/api/authorization_controller.rb
|
|
122
124
|
- app/controllers/standard_id/api/base_controller.rb
|
|
123
125
|
- app/controllers/standard_id/api/oauth/base_controller.rb
|
|
@@ -151,6 +153,7 @@ files:
|
|
|
151
153
|
- app/forms/standard_id/web/signup_form.rb
|
|
152
154
|
- app/helpers/standard_id/application_helper.rb
|
|
153
155
|
- app/jobs/standard_id/application_job.rb
|
|
156
|
+
- app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb
|
|
154
157
|
- app/jobs/standard_id/cleanup_expired_sessions_job.rb
|
|
155
158
|
- app/mailers/standard_id/application_mailer.rb
|
|
156
159
|
- app/models/concerns/standard_id/account_associations.rb
|
|
@@ -167,6 +170,7 @@ files:
|
|
|
167
170
|
- app/models/standard_id/identifier.rb
|
|
168
171
|
- app/models/standard_id/password_credential.rb
|
|
169
172
|
- app/models/standard_id/phone_number_identifier.rb
|
|
173
|
+
- app/models/standard_id/refresh_token.rb
|
|
170
174
|
- app/models/standard_id/service_session.rb
|
|
171
175
|
- app/models/standard_id/session.rb
|
|
172
176
|
- app/models/standard_id/username_identifier.rb
|
|
@@ -192,6 +196,8 @@ files:
|
|
|
192
196
|
- db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb
|
|
193
197
|
- db/migrate/20250903063000_create_standard_id_authorization_codes.rb
|
|
194
198
|
- db/migrate/20250907090000_create_standard_id_code_challenges.rb
|
|
199
|
+
- db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb
|
|
200
|
+
- db/migrate/20260311000000_create_standard_id_refresh_tokens.rb
|
|
195
201
|
- lib/generators/standard_id/install/install_generator.rb
|
|
196
202
|
- lib/generators/standard_id/install/templates/standard_id.rb
|
|
197
203
|
- lib/standard_config.rb
|
|
@@ -265,6 +271,7 @@ metadata:
|
|
|
265
271
|
homepage_uri: https://github.com/rarebit-one/standard_id
|
|
266
272
|
source_code_uri: https://github.com/rarebit-one/standard_id
|
|
267
273
|
changelog_uri: https://github.com/rarebit-one/standard_id/blob/main/CHANGELOG.md
|
|
274
|
+
bug_tracker_uri: https://github.com/rarebit-one/standard_id/issues
|
|
268
275
|
rdoc_options: []
|
|
269
276
|
require_paths:
|
|
270
277
|
- lib
|