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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/standard_id/controller_policy.rb +99 -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_phone/base_controller.rb +2 -0
- data/lib/standard_id/authorization_bypass.rb +121 -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/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/version.rb +1 -1
- data/lib/standard_id.rb +6 -0
- metadata +40 -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
@
|
|
98
|
-
@
|
|
99
|
-
@
|
|
100
|
-
@
|
|
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
|
-
@
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
201
|
+
@jwks_ref.compare_and_set(nil, computed)
|
|
202
|
+
@jwks_ref.get
|
|
177
203
|
end
|
|
178
204
|
end
|
|
179
205
|
|