standard_id 0.3.2 → 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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/standard_id/controller_policy.rb +99 -0
  3. data/app/controllers/standard_id/api/authorization_controller.rb +2 -0
  4. data/app/controllers/standard_id/api/base_controller.rb +1 -0
  5. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +2 -0
  6. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +2 -0
  7. data/app/controllers/standard_id/api/oidc/logout_controller.rb +2 -0
  8. data/app/controllers/standard_id/api/passwordless_controller.rb +2 -0
  9. data/app/controllers/standard_id/api/userinfo_controller.rb +2 -0
  10. data/app/controllers/standard_id/api/well_known/jwks_controller.rb +6 -0
  11. data/app/controllers/standard_id/web/account_controller.rb +2 -0
  12. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +2 -0
  13. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  14. data/app/controllers/standard_id/web/login_controller.rb +2 -0
  15. data/app/controllers/standard_id/web/login_verify_controller.rb +12 -84
  16. data/app/controllers/standard_id/web/logout_controller.rb +7 -0
  17. data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +2 -0
  18. data/app/controllers/standard_id/web/reset_password/start_controller.rb +2 -0
  19. data/app/controllers/standard_id/web/sessions_controller.rb +2 -0
  20. data/app/controllers/standard_id/web/signup_controller.rb +2 -0
  21. data/app/controllers/standard_id/web/verify_email/base_controller.rb +2 -0
  22. data/app/controllers/standard_id/web/verify_phone/base_controller.rb +2 -0
  23. data/lib/standard_id/authorization_bypass.rb +121 -0
  24. data/lib/standard_id/jwt_service.rb +41 -15
  25. data/lib/standard_id/oauth/password_flow.rb +5 -1
  26. data/lib/standard_id/oauth/passwordless_otp_flow.rb +10 -61
  27. data/lib/standard_id/passwordless/verification_service.rb +227 -0
  28. data/lib/standard_id/testing/authentication_helpers.rb +75 -0
  29. data/lib/standard_id/testing/factories/credentials.rb +24 -0
  30. data/lib/standard_id/testing/factories/identifiers.rb +37 -0
  31. data/lib/standard_id/testing/factories/oauth.rb +89 -0
  32. data/lib/standard_id/testing/factories/sessions.rb +112 -0
  33. data/lib/standard_id/testing/factory_bot.rb +7 -0
  34. data/lib/standard_id/testing/request_helpers.rb +60 -0
  35. data/lib/standard_id/testing.rb +26 -0
  36. data/lib/standard_id/version.rb +1 -1
  37. data/lib/standard_id.rb +6 -0
  38. metadata +40 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f0a6bc5ee9c7ea15e8f9ea7bee06f7cb080da52fade31a85bb0d0ead5207552
4
- data.tar.gz: 8dffbedfccd7f2434fbf77befe544ebb0c2dc55e4bbbbfb105c11254543e8103
3
+ metadata.gz: 6f6c0f3655d0ae20c828fa745d3c600bcf9b0b11521c1c498eb601d35e24e3e2
4
+ data.tar.gz: 1f3cca500ec2bf46a4e92b9d8d4a14c7c5fff2d898567a616094b47fd7af5312
5
5
  SHA512:
6
- metadata.gz: dd96b17327a9468bd2128dea33826450009949e73b6579e36f7c422cd5f9e8aa36a1201de4019e523f79f709539a2cc2e4f8c92dabb15fdef0505488fb3c9cb1
7
- data.tar.gz: 74e0c84b638ecb2ab7dbc7f7303617e8b2614fc62f9c8218f33da27136f267f41f6ca676afdff2197395eff5410b538fc186f3c9c9f1c7148cd43bc653b22b57
6
+ metadata.gz: d9cb2f2cafa4ddfb0af5839168db2566769b968689a3386c5519b0dc0e06bf0a5cd1a1434df57885549000c85e5eabb2b2608b276338f969625833d09de0c118
7
+ data.tar.gz: 4a382d6268709dfe42bac61e60f969153e5054831345250ada9c08436f0681fe5a4b58454ffbf3c00f506ec40308f031e28ac65acd52c8484f7c1adbd0c88738
@@ -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
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class AuthorizationController < BaseController
4
+ public_controller
5
+
4
6
  include ActionController::Cookies
5
7
 
6
8
  skip_before_action :validate_content_type!
@@ -1,6 +1,7 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class BaseController < ActionController::API
4
+ include StandardId::ControllerPolicy
4
5
  include StandardId::ApiAuthentication
5
6
  include StandardId::SetCurrentRequestDetails
6
7
 
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Api::Oauth
3
3
  module Callback
4
4
  class ProvidersController < BaseController
5
+ public_controller
6
+
5
7
  include StandardId::SocialAuthentication
6
8
 
7
9
  skip_before_action :validate_content_type!
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Api
3
3
  module Oauth
4
4
  class TokensController < BaseController
5
+ public_controller
6
+
5
7
  skip_before_action :validate_content_type!
6
8
 
7
9
  FLOW_STRATEGIES = {
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Api
3
3
  module Oidc
4
4
  class LogoutController < ::StandardId::Api::BaseController
5
+ public_controller
6
+
5
7
  include ActionController::Cookies
6
8
 
7
9
  skip_before_action :validate_content_type!
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class PasswordlessController < BaseController
4
+ public_controller
5
+
4
6
  include StandardId::PasswordlessStrategy
5
7
 
6
8
  def start
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class UserinfoController < BaseController
4
+ authenticated_controller
5
+
4
6
  skip_before_action :validate_content_type!
5
7
 
6
8
  def show
@@ -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,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class AccountController < BaseController
4
+ authenticated_controller
5
+
4
6
  def show
5
7
  @account = current_account
6
8
  @sessions = current_account.sessions.active.order(created_at: :desc)
@@ -3,6 +3,8 @@ module StandardId
3
3
  module Auth
4
4
  module Callback
5
5
  class ProvidersController < StandardId::Web::BaseController
6
+ public_controller
7
+
6
8
  include StandardId::WebAuthentication
7
9
  include StandardId::SocialAuthentication
8
10
  include StandardId::Web::SocialLoginParams
@@ -1,6 +1,7 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class BaseController < ApplicationController
4
+ include StandardId::ControllerPolicy
4
5
  include StandardId::WebAuthentication
5
6
  include StandardId::SetCurrentRequestDetails
6
7
 
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class LoginController < BaseController
4
+ public_controller
5
+
4
6
  include StandardId::InertiaRendering
5
7
  include StandardId::Web::SocialLoginParams
6
8
  include StandardId::PasswordlessStrategy
@@ -1,10 +1,9 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class LoginVerifyController < BaseController
4
- include StandardId::InertiaRendering
5
- include StandardId::PasswordlessStrategy
4
+ public_controller
6
5
 
7
- class OtpVerificationFailed < StandardError; end
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
- # Record failed attempts outside the main transaction so they persist
31
- challenge = find_active_challenge
32
- attempts = record_attempt(challenge, code)
33
-
34
- if challenge.blank? || !ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
35
- emit_otp_validation_failed(attempts) if challenge.present?
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
- flash.now[:alert] = "Invalid or expired verification code"
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
- strategy = strategy_for(@otp_data[:connection])
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
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module ResetPassword
4
4
  class ConfirmController < BaseController
5
+ public_controller
6
+
5
7
  layout "public"
6
8
 
7
9
  skip_before_action :require_browser_session!, only: [:show, :update]
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module ResetPassword
4
4
  class StartController < BaseController
5
+ public_controller
6
+
5
7
  layout "public"
6
8
 
7
9
  skip_before_action :require_browser_session!, only: [:show, :create]
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class SessionsController < BaseController
4
+ authenticated_controller
5
+
4
6
  def index
5
7
  @sessions = current_account.sessions.active.order(created_at: :desc)
6
8
  @current_session = current_session
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class SignupController < BaseController
4
+ public_controller
5
+
4
6
  include StandardId::InertiaRendering
5
7
 
6
8
  layout "public"
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module VerifyEmail
4
4
  class BaseController < StandardId::Web::BaseController
5
+ public_controller
6
+
5
7
  skip_before_action :require_browser_session!
6
8
  end
7
9
  end
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module VerifyPhone
4
4
  class BaseController < StandardId::Web::BaseController
5
+ public_controller
6
+
5
7
  skip_before_action :require_browser_session!
6
8
  end
7
9
  end
@@ -0,0 +1,121 @@
1
+ module StandardId
2
+ module AuthorizationBypass
3
+ FRAMEWORK_CALLBACKS = {
4
+ action_policy: :verify_authorized,
5
+ pundit: :verify_authorized,
6
+ cancancan: :check_authorization
7
+ }.freeze
8
+
9
+ MUTEX = Mutex.new
10
+ private_constant :MUTEX
11
+
12
+ class << self
13
+ # Skips the host app's authorization callback on all engine controllers,
14
+ # and also skips authenticate_account! on public controllers (login,
15
+ # signup, callbacks, etc.) since those must be accessible without a session.
16
+ #
17
+ # In production (eager_load=true), controllers are already loaded when
18
+ # this runs so the registry is populated. In development (eager_load=false),
19
+ # controllers are loaded lazily on first request; newly registered
20
+ # controllers receive skips immediately via apply_to_controller (called
21
+ # from ControllerPolicy.register). The to_prepare block handles class
22
+ # reloading — after Zeitwerk unloads/reloads classes, the freshly loaded
23
+ # controllers re-register and receive skips again.
24
+ def apply(framework: nil, callback: nil)
25
+ if framework && callback
26
+ raise ArgumentError, "Provide framework: or callback:, not both"
27
+ end
28
+
29
+ register_prepare = false
30
+
31
+ MUTEX.synchronize do
32
+ # Guard against duplicate to_prepare registrations if called more than
33
+ # once (e.g. in tests or misconfigured initializers). skip_before_action
34
+ # is idempotent so duplicates are harmless, but this keeps things tidy.
35
+ return if @callback_name
36
+
37
+ @callback_name = resolve_callback(framework, callback)
38
+ # @prepared is intentionally NOT cleared by reset!. This ensures
39
+ # at most one to_prepare block is registered per process lifetime.
40
+ # Trade-off: after reset! + apply (e.g. in tests switching
41
+ # frameworks), the to_prepare code path is not re-registered, so
42
+ # it can only be verified by the first test that calls apply.
43
+ register_prepare = !@prepared
44
+ @prepared = true
45
+ end
46
+
47
+ apply_skips!
48
+
49
+ # Re-apply after class reloading in development. In dev (eager_load=false),
50
+ # reset_registry! + apply_skips! is effectively a no-op because the
51
+ # registry is empty at this point — lazy-loaded controllers haven't
52
+ # registered yet. The real work for lazy-loaded controllers is done by
53
+ # apply_to_controller (called from ControllerPolicy.register). This
54
+ # block is still needed because after a Zeitwerk reload, controllers
55
+ # re-register and apply_to_controller fires again for each one, but the
56
+ # reset_registry! here clears stale references to the old class objects
57
+ # to prevent memory leaks in long dev sessions.
58
+ return unless register_prepare
59
+
60
+ Rails.application.config.to_prepare do
61
+ StandardId::ControllerPolicy.reset_registry!
62
+ StandardId::AuthorizationBypass.apply_skips!
63
+ end
64
+ end
65
+
66
+ # Whether apply has been called. Used by ControllerPolicy.register to
67
+ # decide if newly loaded controllers need immediate skip_before_action.
68
+ def applied?
69
+ MUTEX.synchronize { !@callback_name.nil? }
70
+ end
71
+
72
+ # Apply skips to a single controller. Called by ControllerPolicy.register
73
+ # when a controller is lazily loaded after apply has already been called.
74
+ def apply_to_controller(controller, policy)
75
+ callback = MUTEX.synchronize { @callback_name }
76
+ return unless callback
77
+
78
+ controller.skip_before_action callback, raise: false
79
+ if policy == :public
80
+ # authenticate_account! is defined in WebAuthentication, not on API
81
+ # controllers. raise: false ensures this is a safe no-op for API
82
+ # controllers that don't have the callback.
83
+ controller.skip_before_action :authenticate_account!, raise: false
84
+ end
85
+ end
86
+
87
+ # @api private — called internally by apply and the to_prepare block.
88
+ # Must remain public because it is invoked from a to_prepare lambda
89
+ # registered in apply, which executes outside this module's scope.
90
+ def apply_skips!
91
+ StandardId::ControllerPolicy.registry_snapshot.each do |policy, controllers|
92
+ controllers.each { |controller| apply_to_controller(controller, policy) }
93
+ end
94
+ end
95
+
96
+ # @api private — intended for test isolation only.
97
+ # NOTE: This clears @callback_name (so applied? returns false and apply
98
+ # can be called again with a different framework) but intentionally does
99
+ # NOT clear @prepared, so no additional to_prepare block is registered.
100
+ def reset!
101
+ MUTEX.synchronize { @callback_name = nil }
102
+ end
103
+
104
+ private
105
+
106
+ def resolve_callback(framework, callback)
107
+ if callback
108
+ callback.to_sym
109
+ elsif framework
110
+ FRAMEWORK_CALLBACKS.fetch(framework.to_sym) do
111
+ raise ArgumentError, "Unknown framework: #{framework}. " \
112
+ "Supported: #{FRAMEWORK_CALLBACKS.keys.join(', ')}. " \
113
+ "Or pass callback: :your_callback_name instead."
114
+ end
115
+ else
116
+ raise ArgumentError, "Provide either framework: or callback:"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,5 +1,6 @@
1
1
  require "jwt"
2
2
  require "concurrent/delay"
3
+ require "concurrent/atomic/atomic_reference"
3
4
  require "openssl"
4
5
  require "digest"
5
6
 
@@ -34,6 +35,11 @@ module StandardId
34
35
  end
35
36
  end
36
37
 
38
+ @signing_key_ref = Concurrent::AtomicReference.new
39
+ @key_id_ref = Concurrent::AtomicReference.new
40
+ @previous_keys_ref = Concurrent::AtomicReference.new
41
+ @jwks_ref = Concurrent::AtomicReference.new
42
+
37
43
  def self.session_class
38
44
  SESSION_CLASS.value
39
45
  end
@@ -52,7 +58,11 @@ module StandardId
52
58
 
53
59
  def self.signing_key
54
60
  if asymmetric?
55
- @signing_key_cache ||= parse_private_key(StandardId.config.oauth.signing_key)
61
+ @signing_key_ref.get || begin
62
+ computed = parse_private_key(StandardId.config.oauth.signing_key)
63
+ @signing_key_ref.compare_and_set(nil, computed)
64
+ @signing_key_ref.get
65
+ end
56
66
  else
57
67
  Rails.application.secret_key_base
58
68
  end
@@ -74,16 +84,24 @@ module StandardId
74
84
 
75
85
  # Generate stable key ID from public key fingerprint
76
86
  # Use public_to_pem which works for both RSA and EC keys
77
- @key_id ||= Digest::SHA256.hexdigest(signing_key.public_to_pem)[0..7]
87
+ @key_id_ref.get || begin
88
+ computed = Digest::SHA256.hexdigest(signing_key.public_to_pem)[0..7]
89
+ @key_id_ref.compare_and_set(nil, computed)
90
+ @key_id_ref.get
91
+ end
78
92
  end
79
93
 
80
94
  def self.previous_keys
81
95
  return [] unless asymmetric?
82
96
 
83
- @previous_keys_cache ||= Array(StandardId.config.oauth.previous_signing_keys).filter_map do |entry|
84
- parse_previous_key_entry(entry)
85
- rescue StandardError
86
- nil
97
+ @previous_keys_ref.get || begin
98
+ computed = Array(StandardId.config.oauth.previous_signing_keys).filter_map do |entry|
99
+ parse_previous_key_entry(entry)
100
+ rescue StandardError
101
+ nil
102
+ end
103
+ @previous_keys_ref.compare_and_set(nil, computed)
104
+ @previous_keys_ref.get
87
105
  end
88
106
  end
89
107
 
@@ -93,11 +111,15 @@ module StandardId
93
111
  [{ kid: key_id, key: verification_key, algorithm: algorithm }] + previous_keys
94
112
  end
95
113
 
114
+ # NOTE: Individual resets are atomic but the group is not — a concurrent
115
+ # reader between two .set(nil) calls may see a mix of old and new values.
116
+ # This is acceptable: key rotation is an infrequent operator action and
117
+ # the worst case is one request using a stale (but still valid) key.
96
118
  def self.reset_cached_key!
97
- @key_id = nil
98
- @signing_key_cache = nil
99
- @previous_keys_cache = nil
100
- @jwks = nil
119
+ @key_id_ref.set(nil)
120
+ @signing_key_ref.set(nil)
121
+ @previous_keys_ref.set(nil)
122
+ @jwks_ref.set(nil)
101
123
  end
102
124
 
103
125
  def self.encode(payload, expires_in: 1.hour)
@@ -168,12 +190,16 @@ module StandardId
168
190
  def self.jwks
169
191
  return nil unless asymmetric?
170
192
 
171
- @jwks ||= begin
172
- exported_keys = all_verification_keys.map do |entry|
173
- jwk = JWT::JWK.new(entry[:key], kid: entry[:kid]).export
174
- jwk.merge(alg: entry[:algorithm], use: "sig")
193
+ @jwks_ref.get || begin
194
+ computed = begin
195
+ exported_keys = all_verification_keys.map do |entry|
196
+ jwk = JWT::JWK.new(entry[:key], kid: entry[:kid]).export
197
+ jwk.merge(alg: entry[:algorithm], use: "sig")
198
+ end
199
+ { keys: exported_keys }
175
200
  end
176
- { keys: exported_keys }
201
+ @jwks_ref.compare_and_set(nil, computed)
202
+ @jwks_ref.get
177
203
  end
178
204
  end
179
205