standard_id 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/concerns/standard_id/api_authentication.rb +6 -0
- data/app/controllers/concerns/standard_id/controller_policy.rb +99 -0
- data/app/controllers/concerns/standard_id/sentry_context.rb +36 -0
- data/app/controllers/concerns/standard_id/set_current_request_details.rb +1 -1
- data/app/controllers/concerns/standard_id/web_authentication.rb +5 -0
- data/app/controllers/standard_id/api/authorization_controller.rb +2 -0
- data/app/controllers/standard_id/api/base_controller.rb +1 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +2 -0
- data/app/controllers/standard_id/api/oauth/tokens_controller.rb +2 -0
- data/app/controllers/standard_id/api/oidc/logout_controller.rb +2 -0
- data/app/controllers/standard_id/api/passwordless_controller.rb +2 -0
- data/app/controllers/standard_id/api/userinfo_controller.rb +2 -0
- data/app/controllers/standard_id/api/well_known/jwks_controller.rb +6 -0
- data/app/controllers/standard_id/web/account_controller.rb +2 -0
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +2 -0
- data/app/controllers/standard_id/web/base_controller.rb +1 -0
- data/app/controllers/standard_id/web/login_controller.rb +2 -0
- data/app/controllers/standard_id/web/login_verify_controller.rb +12 -84
- data/app/controllers/standard_id/web/logout_controller.rb +7 -0
- data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +2 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +2 -0
- data/app/controllers/standard_id/web/sessions_controller.rb +2 -0
- data/app/controllers/standard_id/web/signup_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_email/base_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -1
- data/app/controllers/standard_id/web/verify_phone/base_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -1
- data/lib/standard_id/api/session_manager.rb +7 -3
- data/lib/standard_id/api/token_manager.rb +2 -2
- data/lib/standard_id/authorization_bypass.rb +121 -0
- data/lib/standard_id/config/schema.rb +2 -0
- data/lib/standard_id/jwt_service.rb +41 -15
- data/lib/standard_id/oauth/password_flow.rb +5 -1
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +10 -61
- data/lib/standard_id/passwordless/base_strategy.rb +1 -1
- data/lib/standard_id/passwordless/verification_service.rb +227 -0
- data/lib/standard_id/testing/authentication_helpers.rb +75 -0
- data/lib/standard_id/testing/factories/credentials.rb +24 -0
- data/lib/standard_id/testing/factories/identifiers.rb +37 -0
- data/lib/standard_id/testing/factories/oauth.rb +89 -0
- data/lib/standard_id/testing/factories/sessions.rb +112 -0
- data/lib/standard_id/testing/factory_bot.rb +7 -0
- data/lib/standard_id/testing/request_helpers.rb +60 -0
- data/lib/standard_id/testing.rb +26 -0
- data/lib/standard_id/utils/ip_normalizer.rb +16 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +14 -1
- data/lib/standard_id/web/token_manager.rb +1 -1
- data/lib/standard_id.rb +7 -0
- metadata +42 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f6c0f3655d0ae20c828fa745d3c600bcf9b0b11521c1c498eb601d35e24e3e2
|
|
4
|
+
data.tar.gz: 1f3cca500ec2bf46a4e92b9d8d4a14c7c5fff2d898567a616094b47fd7af5312
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d9cb2f2cafa4ddfb0af5839168db2566769b968689a3386c5519b0dc0e06bf0a5cd1a1434df57885549000c85e5eabb2b2608b276338f969625833d09de0c118
|
|
7
|
+
data.tar.gz: 4a382d6268709dfe42bac61e60f969153e5054831345250ada9c08436f0681fe5a4b58454ffbf3c00f506ec40308f031e28ac65acd52c8484f7c1adbd0c88738
|
|
@@ -2,6 +2,12 @@ module StandardId
|
|
|
2
2
|
module ApiAuthentication
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
included do
|
|
6
|
+
if StandardId.config.alias_current_user
|
|
7
|
+
define_method(:current_user) { current_account }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
5
11
|
delegate :current_session, :current_account, :revoke_current_session!, to: :session_manager
|
|
6
12
|
|
|
7
13
|
private
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "monitor"
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
module StandardId
|
|
5
|
+
module ControllerPolicy
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :_standard_id_auth_policy, instance_writer: false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
# Declares this controller as public (no host-app auth required).
|
|
14
|
+
def public_controller
|
|
15
|
+
self._standard_id_auth_policy = :public
|
|
16
|
+
ControllerPolicy.register(self, :public)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Declares this controller as authenticated (requires host-app
|
|
20
|
+
# authentication but not authorization).
|
|
21
|
+
def authenticated_controller
|
|
22
|
+
self._standard_id_auth_policy = :authenticated
|
|
23
|
+
ControllerPolicy.register(self, :authenticated)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Auto-register subclasses that inherit a policy declaration.
|
|
27
|
+
# This ensures controllers like VerifyEmail::ConfirmController
|
|
28
|
+
# (which inherits from VerifyEmail::BaseController) appear in
|
|
29
|
+
# the registry and receive AuthorizationBypass skips.
|
|
30
|
+
def inherited(subclass)
|
|
31
|
+
super
|
|
32
|
+
policy = subclass._standard_id_auth_policy
|
|
33
|
+
ControllerPolicy.register(subclass, policy) if policy
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Monitor instead of Mutex: registry is called from within synchronized
|
|
38
|
+
# blocks (e.g. register, public_controllers), so we need reentrant locking.
|
|
39
|
+
LOCK = Monitor.new
|
|
40
|
+
private_constant :LOCK
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def public_controllers
|
|
44
|
+
LOCK.synchronize { registry[:public].dup }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def authenticated_controllers
|
|
48
|
+
LOCK.synchronize { registry[:authenticated].dup }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def all_controllers
|
|
52
|
+
LOCK.synchronize { registry[:public] + registry[:authenticated] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Registers a controller under the given policy. Raises if the
|
|
56
|
+
# controller is already registered under the opposite policy.
|
|
57
|
+
# Re-registering under the same policy is a safe no-op (Set semantics).
|
|
58
|
+
def register(controller, policy)
|
|
59
|
+
LOCK.synchronize do
|
|
60
|
+
other = policy == :public ? :authenticated : :public
|
|
61
|
+
if registry[other].include?(controller)
|
|
62
|
+
raise ArgumentError, "#{controller} is already registered as #{other}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
registry[policy] << controller
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# If AuthorizationBypass has already been applied, apply skips to this
|
|
69
|
+
# newly registered controller immediately. This handles dev-mode lazy
|
|
70
|
+
# loading where controllers register after the initial apply_skips! call.
|
|
71
|
+
AuthorizationBypass.apply_to_controller(controller, policy) if AuthorizationBypass.applied?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns a point-in-time copy of the registry, safe to iterate
|
|
75
|
+
# without holding the lock.
|
|
76
|
+
def registry_snapshot
|
|
77
|
+
LOCK.synchronize do
|
|
78
|
+
(@registry ||= { public: Set.new, authenticated: Set.new })
|
|
79
|
+
.transform_values(&:dup)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def registry
|
|
86
|
+
LOCK.synchronize { @registry ||= { public: Set.new, authenticated: Set.new } }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
public
|
|
90
|
+
|
|
91
|
+
# @api private — intended for test isolation only.
|
|
92
|
+
def reset_registry!
|
|
93
|
+
LOCK.synchronize do
|
|
94
|
+
@registry = { public: Set.new, authenticated: Set.new }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Sets Sentry user context from the current authenticated account.
|
|
3
|
+
#
|
|
4
|
+
# This is a standalone concern that host apps can include in their
|
|
5
|
+
# ApplicationController to automatically set Sentry user context
|
|
6
|
+
# for each request. It eliminates the need for apps to write
|
|
7
|
+
# their own SentryContext boilerplate.
|
|
8
|
+
#
|
|
9
|
+
# Safe to include even when the Sentry gem is not installed -- the
|
|
10
|
+
# callback is a no-op if `Sentry` is not defined.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# class ApplicationController < ActionController::Base
|
|
14
|
+
# include StandardId::WebAuthentication
|
|
15
|
+
# include StandardId::SentryContext
|
|
16
|
+
# end
|
|
17
|
+
module SentryContext
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
included do
|
|
21
|
+
before_action :set_standard_id_sentry_context
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def set_standard_id_sentry_context
|
|
27
|
+
return unless defined?(Sentry)
|
|
28
|
+
return unless respond_to?(:current_account, true) && current_account.present?
|
|
29
|
+
|
|
30
|
+
context = { id: current_account.id }
|
|
31
|
+
context[:session_id] = current_session.id if respond_to?(:current_session, true) && current_session.present?
|
|
32
|
+
|
|
33
|
+
Sentry.set_user(context)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -12,7 +12,7 @@ module StandardId
|
|
|
12
12
|
return unless defined?(::Current)
|
|
13
13
|
|
|
14
14
|
::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
|
|
15
|
-
::Current.ip_address = request.remote_ip if ::Current.respond_to?(:ip_address=)
|
|
15
|
+
::Current.ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip) if ::Current.respond_to?(:ip_address=)
|
|
16
16
|
::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -5,6 +5,11 @@ module StandardId
|
|
|
5
5
|
included do
|
|
6
6
|
include StandardId::InertiaSupport
|
|
7
7
|
helper_method :current_account, :authenticated?
|
|
8
|
+
|
|
9
|
+
if StandardId.config.alias_current_user
|
|
10
|
+
define_method(:current_user) { current_account }
|
|
11
|
+
helper_method :current_user
|
|
12
|
+
end
|
|
8
13
|
end
|
|
9
14
|
|
|
10
15
|
delegate :current_session, :current_account, :revoke_current_session!, to: :session_manager
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
module StandardId
|
|
4
4
|
module Api
|
|
5
5
|
module WellKnown
|
|
6
|
+
# Inherits from ActionController::API (not Api::BaseController) to avoid
|
|
7
|
+
# content-type validation and no-store cache headers — JWKS is a public,
|
|
8
|
+
# cacheable endpoint. Includes ControllerPolicy directly as a result.
|
|
6
9
|
class JwksController < ActionController::API
|
|
10
|
+
include StandardId::ControllerPolicy
|
|
11
|
+
public_controller
|
|
12
|
+
|
|
7
13
|
def show
|
|
8
14
|
jwks = StandardId::JwtService.jwks
|
|
9
15
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
module Web
|
|
3
3
|
class LoginVerifyController < BaseController
|
|
4
|
-
|
|
5
|
-
include StandardId::PasswordlessStrategy
|
|
4
|
+
public_controller
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
include StandardId::InertiaRendering
|
|
8
7
|
|
|
9
8
|
layout "public"
|
|
10
9
|
|
|
@@ -27,42 +26,21 @@ module StandardId
|
|
|
27
26
|
return
|
|
28
27
|
end
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
result = StandardId::Passwordless::VerificationService.verify(
|
|
30
|
+
connection: @otp_data[:connection],
|
|
31
|
+
username: @otp_data[:username],
|
|
32
|
+
code: code,
|
|
33
|
+
request: request
|
|
34
|
+
)
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
unless result.success?
|
|
37
|
+
flash.now[:alert] = result.error
|
|
38
38
|
render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
|
|
39
39
|
return
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
begin
|
|
45
|
-
ActiveRecord::Base.transaction do
|
|
46
|
-
# Re-fetch with lock inside transaction to prevent concurrent use
|
|
47
|
-
locked_challenge = StandardId::CodeChallenge.lock.find(challenge.id)
|
|
48
|
-
raise OtpVerificationFailed unless locked_challenge.active?
|
|
49
|
-
|
|
50
|
-
account = strategy.find_or_create_account(@otp_data[:username])
|
|
51
|
-
locked_challenge.use!
|
|
52
|
-
|
|
53
|
-
emit_otp_validated(account, locked_challenge)
|
|
54
|
-
session_manager.sign_in_account(account)
|
|
55
|
-
emit_authentication_succeeded(account)
|
|
56
|
-
end
|
|
57
|
-
rescue OtpVerificationFailed
|
|
58
|
-
flash.now[:alert] = "Invalid or expired verification code"
|
|
59
|
-
render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
|
|
60
|
-
return
|
|
61
|
-
rescue ActiveRecord::RecordInvalid => e
|
|
62
|
-
flash.now[:alert] = "Unable to complete sign in: #{e.record.errors.full_messages.to_sentence}"
|
|
63
|
-
render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
|
|
64
|
-
return
|
|
65
|
-
end
|
|
42
|
+
session_manager.sign_in_account(result.account)
|
|
43
|
+
emit_authentication_succeeded(result.account)
|
|
66
44
|
|
|
67
45
|
session.delete(:standard_id_otp_payload)
|
|
68
46
|
|
|
@@ -98,56 +76,6 @@ module StandardId
|
|
|
98
76
|
end
|
|
99
77
|
end
|
|
100
78
|
|
|
101
|
-
def find_active_challenge
|
|
102
|
-
StandardId::CodeChallenge.active.find_by(
|
|
103
|
-
realm: "authentication",
|
|
104
|
-
channel: @otp_data[:connection],
|
|
105
|
-
target: @otp_data[:username]
|
|
106
|
-
)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def record_attempt(challenge, code)
|
|
110
|
-
return 0 if challenge.blank?
|
|
111
|
-
return 0 if ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
|
|
112
|
-
|
|
113
|
-
attempts = (challenge.metadata["attempts"] || 0) + 1
|
|
114
|
-
challenge.update!(metadata: challenge.metadata.merge("attempts" => attempts))
|
|
115
|
-
|
|
116
|
-
max_attempts = StandardId.config.passwordless.max_attempts
|
|
117
|
-
challenge.use! if attempts >= max_attempts
|
|
118
|
-
|
|
119
|
-
attempts
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def emit_otp_validated(account, challenge)
|
|
123
|
-
StandardId::Events.publish(
|
|
124
|
-
StandardId::Events::OTP_VALIDATED,
|
|
125
|
-
account: account,
|
|
126
|
-
channel: @otp_data[:connection]
|
|
127
|
-
)
|
|
128
|
-
StandardId::Events.publish(
|
|
129
|
-
StandardId::Events::PASSWORDLESS_CODE_VERIFIED,
|
|
130
|
-
code_challenge: challenge,
|
|
131
|
-
account: account,
|
|
132
|
-
channel: @otp_data[:connection]
|
|
133
|
-
)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def emit_otp_validation_failed(attempts)
|
|
137
|
-
StandardId::Events.publish(
|
|
138
|
-
StandardId::Events::OTP_VALIDATION_FAILED,
|
|
139
|
-
identifier: @otp_data[:username],
|
|
140
|
-
channel: @otp_data[:connection],
|
|
141
|
-
attempts: attempts
|
|
142
|
-
)
|
|
143
|
-
StandardId::Events.publish(
|
|
144
|
-
StandardId::Events::PASSWORDLESS_CODE_FAILED,
|
|
145
|
-
identifier: @otp_data[:username],
|
|
146
|
-
channel: @otp_data[:connection],
|
|
147
|
-
attempts: attempts
|
|
148
|
-
)
|
|
149
|
-
end
|
|
150
|
-
|
|
151
79
|
def emit_authentication_succeeded(account)
|
|
152
80
|
StandardId::Events.publish(
|
|
153
81
|
StandardId::Events::AUTHENTICATION_SUCCEEDED,
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
module Web
|
|
3
3
|
class LogoutController < BaseController
|
|
4
|
+
# Classified as authenticated (not public) because logout requires
|
|
5
|
+
# host-app authentication. The controller handles unauthenticated
|
|
6
|
+
# users gracefully via redirect_if_not_authenticated rather than
|
|
7
|
+
# raising, and skips require_browser_session! to allow session
|
|
8
|
+
# revocation even with an expired browser session.
|
|
9
|
+
authenticated_controller
|
|
10
|
+
|
|
4
11
|
skip_before_action :require_browser_session!, only: [:create]
|
|
5
12
|
|
|
6
13
|
before_action :redirect_if_not_authenticated
|
|
@@ -12,9 +12,7 @@ module StandardId
|
|
|
12
12
|
|
|
13
13
|
def current_account
|
|
14
14
|
return unless current_session
|
|
15
|
-
@current_account ||=
|
|
16
|
-
.find_by(id: current_session.account_id)
|
|
17
|
-
&.tap { |a| a.strict_loading!(false) }
|
|
15
|
+
@current_account ||= load_current_account
|
|
18
16
|
end
|
|
19
17
|
|
|
20
18
|
def revoke_current_session!
|
|
@@ -28,6 +26,12 @@ module StandardId
|
|
|
28
26
|
|
|
29
27
|
private
|
|
30
28
|
|
|
29
|
+
def load_current_account
|
|
30
|
+
scope = StandardId.account_class
|
|
31
|
+
scope = StandardId.config.account_scope.call(scope) if StandardId.config.account_scope
|
|
32
|
+
scope.find_by(id: current_session.account_id)&.tap { |a| a.strict_loading!(false) }
|
|
33
|
+
end
|
|
34
|
+
|
|
31
35
|
def load_current_session
|
|
32
36
|
return @current_session if @current_session.present?
|
|
33
37
|
|
|
@@ -10,7 +10,7 @@ module StandardId
|
|
|
10
10
|
def create_device_session(account, device_id: nil, device_agent: nil)
|
|
11
11
|
StandardId::DeviceSession.create!(
|
|
12
12
|
account:,
|
|
13
|
-
ip_address: @request.remote_ip,
|
|
13
|
+
ip_address: StandardId::Utils::IpNormalizer.normalize(@request.remote_ip),
|
|
14
14
|
device_id: device_id || SecureRandom.uuid,
|
|
15
15
|
device_agent: device_agent || @request.user_agent,
|
|
16
16
|
expires_at: StandardId::DeviceSession.expiry
|
|
@@ -21,7 +21,7 @@ module StandardId
|
|
|
21
21
|
StandardId::ServiceSession.create!(
|
|
22
22
|
account:,
|
|
23
23
|
owner:,
|
|
24
|
-
ip_address: @request.remote_ip,
|
|
24
|
+
ip_address: StandardId::Utils::IpNormalizer.normalize(@request.remote_ip),
|
|
25
25
|
service_name:,
|
|
26
26
|
service_version:,
|
|
27
27
|
metadata: metadata || {},
|