standard_id 0.41.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -1
- data/README.md +251 -13
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +13 -3
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
- data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
- data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
- data/app/models/concerns/standard_id/credentiable.rb +8 -1
- data/app/models/standard_id/application_record.rb +26 -0
- data/app/models/standard_id/authorization_code.rb +3 -1
- data/app/models/standard_id/client_application.rb +11 -0
- data/app/models/standard_id/identifier.rb +1 -0
- data/app/models/standard_id/session.rb +2 -1
- data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
- data/app/views/standard_id/web/login/show.html.erb +5 -5
- data/app/views/standard_id/web/signup/show.html.erb +3 -3
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
- data/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +40 -10
- data/lib/standard_id/config_schema.rb +28 -5
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +21 -0
- data/lib/standard_id/instrumentation.rb +49 -0
- data/lib/standard_id/migration_check.rb +183 -0
- data/lib/standard_id/migration_helpers.rb +65 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
- data/lib/standard_id/oauth/oauth_session_persistence.rb +66 -24
- data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/passwordless.rb +13 -1
- data/lib/standard_id/provider_registry.rb +113 -2
- data/lib/standard_id/providers/base.rb +222 -8
- data/lib/standard_id/providers/plugin_railtie.rb +59 -0
- data/lib/standard_id/scope_config.rb +24 -7
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +1 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +24 -17
- data/config/initializers/migration_helpers.rb +0 -32
|
@@ -37,41 +37,83 @@ module StandardId
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Reuse the device's ACTIVE session row, or start a new one.
|
|
41
|
+
#
|
|
42
|
+
# Only a non-revoked row is eligible. Sign-out (/oauth/revoke under the
|
|
43
|
+
# default :account revocation_scope) revokes every active DeviceSession
|
|
44
|
+
# for the account; reusing that revoked row on the next sign-in — which a
|
|
45
|
+
# bare `find_by(account:, device_id:)` did — linked every new refresh
|
|
46
|
+
# token to a revoked parent, so RefreshTokenFlow#validate_parent_session!
|
|
47
|
+
# refused the very first refresh and the client was bounced to sign-in
|
|
48
|
+
# after every access-token expiry, forever. A revoked row is history (the
|
|
49
|
+
# audit trail and admin session lists still read it); a new sign-in gets
|
|
50
|
+
# a new row. Expiry is deliberately NOT part of eligibility: an expired
|
|
51
|
+
# but unrevoked row is reused with its expires_at bumped, as before.
|
|
40
52
|
def upsert_device_session!(account:, request:, audience:, grant_type:)
|
|
41
53
|
user_agent = request.user_agent
|
|
42
54
|
device_id = stable_device_id(account: account, user_agent: user_agent, audience: audience)
|
|
43
55
|
ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip)
|
|
44
56
|
|
|
45
|
-
# Serialize concurrent upserts for the same account.
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# is unavailable because StandardId::AccountLocking overrides lock!
|
|
51
|
-
# with a business-level method that takes a :reason kwarg.
|
|
52
|
-
# The outer transaction (opened by TokenGrantFlow#generate_token_response)
|
|
57
|
+
# Serialize concurrent upserts for the same account. We acquire a
|
|
58
|
+
# SELECT ... FOR UPDATE on the account row — account.with_lock is
|
|
59
|
+
# unavailable because StandardId::AccountLocking overrides lock! with a
|
|
60
|
+
# business-level method that takes a :reason kwarg. The outer
|
|
61
|
+
# transaction (opened by TokenGrantFlow#generate_token_response)
|
|
53
62
|
# releases the lock on commit/rollback.
|
|
63
|
+
#
|
|
64
|
+
# The lock alone is not the guarantee: the partial unique index from
|
|
65
|
+
# 20260924000000 (one active row per account + device_id) is. The lock
|
|
66
|
+
# keeps the common case free of unique violations; the savepoint below
|
|
67
|
+
# handles the rest, and keeps hosts that have not yet run that
|
|
68
|
+
# migration no worse off than before.
|
|
54
69
|
account.class.where(id: account.id).lock.first
|
|
55
70
|
|
|
56
|
-
existing =
|
|
57
|
-
if existing
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
existing = active_device_session(account: account, device_id: device_id)
|
|
72
|
+
return refresh_device_session!(existing, ip_address: ip_address, user_agent: user_agent) if existing
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
# Savepoint, so a unique violation does not abort the enclosing token
|
|
76
|
+
# transaction (Postgres refuses every later statement in an aborted
|
|
77
|
+
# transaction).
|
|
78
|
+
StandardId::DeviceSession.transaction(requires_new: true) do
|
|
79
|
+
StandardId::DeviceSession.create!(
|
|
80
|
+
account: account,
|
|
81
|
+
device_id: device_id,
|
|
82
|
+
device_agent: user_agent.presence || "OAuth:#{grant_type}",
|
|
83
|
+
ip_address: ip_address || "0.0.0.0",
|
|
84
|
+
expires_at: StandardId::DeviceSession.expiry
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
rescue ActiveRecord::RecordNotUnique
|
|
88
|
+
# A concurrent sign-in for the same device committed its row first.
|
|
89
|
+
# Reuse the winner rather than failing the token request.
|
|
90
|
+
winner = active_device_session(account: account, device_id: device_id)
|
|
91
|
+
raise unless winner
|
|
92
|
+
|
|
93
|
+
refresh_device_session!(winner, ip_address: ip_address, user_agent: user_agent)
|
|
72
94
|
end
|
|
73
95
|
end
|
|
74
96
|
|
|
97
|
+
# Newest first: before the unique index existed, a race could leave two
|
|
98
|
+
# active rows for one device, and an unordered lookup picked one
|
|
99
|
+
# arbitrarily. The migration detaches such duplicates, but hosts that
|
|
100
|
+
# have not run it yet still benefit from a deterministic choice.
|
|
101
|
+
def active_device_session(account:, device_id:)
|
|
102
|
+
StandardId::DeviceSession
|
|
103
|
+
.where(account: account, device_id: device_id, revoked_at: nil)
|
|
104
|
+
.order(created_at: :desc, id: :desc)
|
|
105
|
+
.first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def refresh_device_session!(session, ip_address:, user_agent:)
|
|
109
|
+
session.update!(
|
|
110
|
+
expires_at: StandardId::DeviceSession.expiry,
|
|
111
|
+
ip_address: ip_address || session.ip_address,
|
|
112
|
+
device_agent: user_agent || session.device_agent
|
|
113
|
+
)
|
|
114
|
+
session
|
|
115
|
+
end
|
|
116
|
+
|
|
75
117
|
def stable_device_id(account:, user_agent:, audience:)
|
|
76
118
|
audience_key = Array(audience).join(",")
|
|
77
119
|
Digest::SHA256.hexdigest("oauth:#{audience_key}:#{account.id}:#{user_agent}")[0, 36]
|
|
@@ -14,7 +14,7 @@ module StandardId
|
|
|
14
14
|
# Only the normal rotation path (revoke old + create new) is wrapped
|
|
15
15
|
# in a transaction for atomicity.
|
|
16
16
|
def execute
|
|
17
|
-
|
|
17
|
+
instrumented_authenticate!
|
|
18
18
|
response = nil
|
|
19
19
|
StandardId::RefreshToken.transaction do
|
|
20
20
|
rotate_current_refresh_token!
|
|
@@ -81,6 +81,7 @@ module StandardId
|
|
|
81
81
|
if (successor = graced_successor_for(@current_refresh_token_record))
|
|
82
82
|
emit_reuse_graced_event(@current_refresh_token_record, successor)
|
|
83
83
|
@current_refresh_token_record = successor
|
|
84
|
+
@served_from_graced_successor = true
|
|
84
85
|
else
|
|
85
86
|
# Reuse detected: this token was already rotated. Revoke entire family.
|
|
86
87
|
@current_refresh_token_record.revoke_family!
|
|
@@ -166,9 +167,24 @@ module StandardId
|
|
|
166
167
|
raise ActiveRecord::Rollback
|
|
167
168
|
end
|
|
168
169
|
|
|
170
|
+
# Only a request that presented the lost-the-race token ITSELF is treated
|
|
171
|
+
# as reuse. A request served from a graced successor that loses the race
|
|
172
|
+
# is the leeway's own retry colliding with another retry of the same lost
|
|
173
|
+
# response — a client re-sending the superseded token twice in quick
|
|
174
|
+
# succession. Both passed #graced_successor_for while the successor was
|
|
175
|
+
# untouched; one rotated it. Revoking the family here would kill the
|
|
176
|
+
# token the winning request just issued and log the honest client out,
|
|
177
|
+
# which is exactly what the leeway exists to prevent. The loser gets a
|
|
178
|
+
# plain invalid_grant and the client keeps the winner's response.
|
|
179
|
+
#
|
|
180
|
+
# This does not widen the leeway: the winner already consumed the
|
|
181
|
+
# successor, so a later replay of the superseded token finds a USED
|
|
182
|
+
# successor and revokes the family as before.
|
|
169
183
|
def handle_concurrent_reuse!
|
|
170
184
|
@current_refresh_token_record&.reload
|
|
171
185
|
if @current_refresh_token_record&.revoked?
|
|
186
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid" if @served_from_graced_successor
|
|
187
|
+
|
|
172
188
|
@current_refresh_token_record.revoke_family!
|
|
173
189
|
emit_reuse_detected_event
|
|
174
190
|
raise StandardId::InvalidGrantError, "Refresh token reuse detected"
|
|
@@ -208,7 +224,12 @@ module StandardId
|
|
|
208
224
|
return nil if revoked_record.revoked_at.blank?
|
|
209
225
|
return nil if revoked_record.revoked_at < leeway.seconds.ago
|
|
210
226
|
|
|
211
|
-
|
|
227
|
+
# eager_load(:session), matching the primary lookup in
|
|
228
|
+
# #validate_refresh_token_record!: the successor becomes
|
|
229
|
+
# @current_refresh_token_record, and #validate_parent_session! reads its
|
|
230
|
+
# :session. A bare find_by left that a lazy read, which raises
|
|
231
|
+
# StrictLoadingViolationError (a 500) under strict loading.
|
|
232
|
+
successor = StandardId::RefreshToken.eager_load(:session).find_by(previous_token_id: revoked_record.id)
|
|
212
233
|
return nil unless successor&.active?
|
|
213
234
|
return nil if StandardId::RefreshToken.exists?(previous_token_id: successor.id)
|
|
214
235
|
|
|
@@ -16,18 +16,37 @@ module StandardId
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def execute
|
|
19
|
-
|
|
19
|
+
instrumented_authenticate!
|
|
20
20
|
generate_token_response
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
24
24
|
|
|
25
|
+
# authenticate!, wrapped in the Instrumentation::AUTHENTICATE event.
|
|
26
|
+
def instrumented_authenticate!
|
|
27
|
+
StandardId::Instrumentation.instrument(StandardId::Instrumentation::AUTHENTICATE, instrumentation_payload) do
|
|
28
|
+
authenticate!
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def instrumentation_payload
|
|
33
|
+
{ flow: self.class.name, grant_type: instrumentation_grant_type }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Abstract/anonymous flows (specs) may not implement grant_type; the
|
|
37
|
+
# instrumentation payload must never be what raises.
|
|
38
|
+
def instrumentation_grant_type
|
|
39
|
+
grant_type
|
|
40
|
+
rescue NotImplementedError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
25
44
|
def authenticate!
|
|
26
45
|
raise NotImplementedError, "Subclasses must implement authenticate!"
|
|
27
46
|
end
|
|
28
47
|
|
|
29
48
|
def validate_client_secret!(client_id, client_secret)
|
|
30
|
-
client_secret_credential = StandardId::ClientSecretCredential.active.find_by(client_id: client_id)
|
|
49
|
+
client_secret_credential = StandardId::ClientSecretCredential.active.includes(:client_application).find_by(client_id: client_id)
|
|
31
50
|
unless client_secret_credential&.authenticate_client_secret(client_secret)
|
|
32
51
|
raise StandardId::InvalidClientError, "Client authentication failed"
|
|
33
52
|
end
|
|
@@ -36,7 +55,10 @@ module StandardId
|
|
|
36
55
|
|
|
37
56
|
def generate_token_response
|
|
38
57
|
validate_audience!
|
|
39
|
-
|
|
58
|
+
StandardId::Instrumentation.instrument(
|
|
59
|
+
StandardId::Instrumentation::AUDIENCE_PROFILE_BINDING,
|
|
60
|
+
instrumentation_payload.merge(audience: Array(audience).reject(&:blank?))
|
|
61
|
+
) { enforce_audience_profile_binding! }
|
|
40
62
|
emit_token_issuing
|
|
41
63
|
expires_in = token_expiry
|
|
42
64
|
payload = build_jwt_payload(expires_in)
|
|
@@ -2,6 +2,12 @@ require "standard_id/passwordless/verification_service"
|
|
|
2
2
|
|
|
3
3
|
module StandardId
|
|
4
4
|
module Passwordless
|
|
5
|
+
# Last-resort per-challenge attempt ceiling, used only when neither
|
|
6
|
+
# :max_attempts_per_challenge nor :max_attempts is a positive number. NOT
|
|
7
|
+
# the default: with default config the ceiling is 3 (see
|
|
8
|
+
# .max_attempts_per_challenge).
|
|
9
|
+
FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE = 5
|
|
10
|
+
|
|
5
11
|
class << self
|
|
6
12
|
# Public API for verifying a passwordless OTP code.
|
|
7
13
|
#
|
|
@@ -76,12 +82,18 @@ module StandardId
|
|
|
76
82
|
# Resolve the per-challenge attempt ceiling, preferring the newer
|
|
77
83
|
# :max_attempts_per_challenge setting but falling back to :max_attempts
|
|
78
84
|
# for backwards compatibility with apps that configured the older name.
|
|
85
|
+
#
|
|
86
|
+
# With default config this resolves to 3 (:max_attempts' default).
|
|
87
|
+
# FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE applies only when BOTH settings are
|
|
88
|
+
# unset or non-positive (e.g. `max_attempts = 0` / nil) — a ceiling of
|
|
89
|
+
# zero would burn every challenge on its first wrong code, so it is never
|
|
90
|
+
# honoured.
|
|
79
91
|
def max_attempts_per_challenge
|
|
80
92
|
configured = StandardId.config.passwordless.max_attempts_per_challenge
|
|
81
93
|
return configured.to_i if configured && configured.to_i.positive?
|
|
82
94
|
|
|
83
95
|
legacy = StandardId.config.passwordless.max_attempts.to_i
|
|
84
|
-
legacy.positive? ? legacy :
|
|
96
|
+
legacy.positive? ? legacy : FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
# Minimum seconds that must elapse between successive code requests for
|
|
@@ -5,6 +5,15 @@ module StandardId
|
|
|
5
5
|
class ProviderNotFoundError < StandardError; end
|
|
6
6
|
class InvalidProviderError < StandardError; end
|
|
7
7
|
|
|
8
|
+
# Keys a provider's `config_schema` entry may carry that belong to
|
|
9
|
+
# StandardId (see Providers::Base) rather than to ConfigSchema.
|
|
10
|
+
PROVIDER_FIELD_OPTIONS = %i[env required].freeze
|
|
11
|
+
|
|
12
|
+
# The gem-wide StandardId.deprecator (registered in
|
|
13
|
+
# Rails.application.deprecators), kept as a constant for existing callers.
|
|
14
|
+
# The Base.setup message names its own removal version (1.0).
|
|
15
|
+
DEPRECATOR = StandardId.deprecator
|
|
16
|
+
|
|
8
17
|
@providers = Concurrent::Map.new
|
|
9
18
|
|
|
10
19
|
class << self
|
|
@@ -19,7 +28,7 @@ module StandardId
|
|
|
19
28
|
validate_provider!(provider_class)
|
|
20
29
|
providers[name.to_s] = provider_class
|
|
21
30
|
declare_config_schema(provider_class)
|
|
22
|
-
provider_class
|
|
31
|
+
run_deprecated_setup(provider_class)
|
|
23
32
|
provider_class
|
|
24
33
|
end
|
|
25
34
|
|
|
@@ -84,10 +93,80 @@ module StandardId
|
|
|
84
93
|
return if schema.nil? || schema.empty?
|
|
85
94
|
|
|
86
95
|
schema.each do |field_name, options|
|
|
87
|
-
|
|
96
|
+
field_options = options.except(*PROVIDER_FIELD_OPTIONS)
|
|
97
|
+
env_name = env_var_for(field_name, options)
|
|
98
|
+
field_options[:default] = env_default(env_name, options[:default]) if env_name
|
|
99
|
+
|
|
100
|
+
StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **field_options)
|
|
88
101
|
end
|
|
89
102
|
end
|
|
90
103
|
|
|
104
|
+
# The ENV variable a provider config field falls back to, or nil.
|
|
105
|
+
#
|
|
106
|
+
# Canonical scheme: the upper-cased field name (`apple_private_key` →
|
|
107
|
+
# `APPLE_PRIVATE_KEY`). A provider overrides it per field with
|
|
108
|
+
# `env: "OTHER_NAME"`, or opts out with `env: false`.
|
|
109
|
+
#
|
|
110
|
+
# @param field_name [Symbol, String]
|
|
111
|
+
# @param options [Hash] the field's config_schema entry
|
|
112
|
+
# @return [String, nil]
|
|
113
|
+
def env_var_for(field_name, options = {})
|
|
114
|
+
env = options.fetch(:env, true)
|
|
115
|
+
return nil if env == false || env.nil?
|
|
116
|
+
|
|
117
|
+
env == true ? field_name.to_s.upcase : env.to_s
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Registered providers the host app has switched on (see
|
|
121
|
+
# Providers::Base.enabled?).
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash{String => Class}] Provider name => class
|
|
124
|
+
def enabled
|
|
125
|
+
all.select { |_name, provider_class| provider_class.enabled? }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Configuration problems across every registered provider.
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash{String => Array<String>}] Provider name => errors, only
|
|
131
|
+
# for providers that have any
|
|
132
|
+
def configuration_errors
|
|
133
|
+
all.each_with_object({}) do |(name, provider_class), errors|
|
|
134
|
+
provider_errors = provider_class.configuration_errors
|
|
135
|
+
errors[name] = provider_errors if provider_errors.any?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Boot-time check that every enabled provider is fully configured.
|
|
140
|
+
#
|
|
141
|
+
# Run by StandardId::Engine once every plugin has registered. A provider
|
|
142
|
+
# whose client ID is set but whose other required fields are not starts
|
|
143
|
+
# its sign-in flow fine and only fails at the callback — after the user
|
|
144
|
+
# has already authenticated with the provider — so this surfaces it at
|
|
145
|
+
# boot instead.
|
|
146
|
+
#
|
|
147
|
+
# Behaviour follows `c.social.provider_misconfiguration`:
|
|
148
|
+
# - `:warn` (default) — log a warning in every environment.
|
|
149
|
+
# - `:raise` — raise StandardId::ConfigurationError in production; log a
|
|
150
|
+
# warning in every other environment, so a developer without production
|
|
151
|
+
# credentials can still boot the app.
|
|
152
|
+
#
|
|
153
|
+
# @param mode [Symbol] Override the configured mode
|
|
154
|
+
# @param logger [Logger, nil]
|
|
155
|
+
# @return [Hash{String => Array<String>}] the errors found
|
|
156
|
+
# @raise [StandardId::ConfigurationError]
|
|
157
|
+
def validate_configuration!(mode: StandardId.config.social.provider_misconfiguration, logger: StandardId.logger)
|
|
158
|
+
errors = configuration_errors
|
|
159
|
+
return errors if errors.empty?
|
|
160
|
+
|
|
161
|
+
message = "StandardId social provider configuration is incomplete: " +
|
|
162
|
+
errors.map { |name, provider_errors| "#{name} (#{provider_errors.join('; ')})" }.join(", ")
|
|
163
|
+
|
|
164
|
+
raise StandardId::ConfigurationError, message if mode.to_s == "raise" && production?
|
|
165
|
+
|
|
166
|
+
logger&.warn("[StandardId] #{message}")
|
|
167
|
+
errors
|
|
168
|
+
end
|
|
169
|
+
|
|
91
170
|
# Get provider by name
|
|
92
171
|
# @param name [Symbol, String] Provider identifier
|
|
93
172
|
# @return [Class] Provider class
|
|
@@ -114,6 +193,38 @@ module StandardId
|
|
|
114
193
|
|
|
115
194
|
private
|
|
116
195
|
|
|
196
|
+
# A default that prefers a non-blank ENV value, then the field's own
|
|
197
|
+
# default. Evaluated lazily (ConfigSchema calls it when the config is
|
|
198
|
+
# built, or on first read of a field declared afterwards), and only when
|
|
199
|
+
# the host never assigned the field — an explicit assignment, even of
|
|
200
|
+
# nil, always wins.
|
|
201
|
+
def env_default(env_name, fallback)
|
|
202
|
+
lambda do
|
|
203
|
+
value = ENV[env_name]
|
|
204
|
+
next value if value.present?
|
|
205
|
+
|
|
206
|
+
fallback.respond_to?(:call) ? fallback.call : fallback
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Providers::Base.setup was removed in 0.42: no known plugin overrode
|
|
211
|
+
# it, and register — its only caller — runs from after_initialize, where
|
|
212
|
+
# a plugin's own Railtie can do the same work. A provider that still
|
|
213
|
+
# defines `setup` keeps working, with a deprecation warning.
|
|
214
|
+
def run_deprecated_setup(provider_class)
|
|
215
|
+
return unless provider_class.respond_to?(:setup)
|
|
216
|
+
|
|
217
|
+
DEPRECATOR.warn(
|
|
218
|
+
"#{provider_class.name || provider_class}.setup is deprecated and will not be called " \
|
|
219
|
+
"by StandardId 1.0. Move provider initialization into the plugin's Railtie."
|
|
220
|
+
)
|
|
221
|
+
provider_class.setup
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def production?
|
|
225
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
226
|
+
end
|
|
227
|
+
|
|
117
228
|
def validate_provider!(provider_class)
|
|
118
229
|
unless provider_class.is_a?(Class)
|
|
119
230
|
raise InvalidProviderError,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "active_support/security_utils"
|
|
3
|
+
|
|
1
4
|
module StandardId
|
|
2
5
|
module Providers
|
|
3
6
|
# Base class for social login providers.
|
|
@@ -225,17 +228,116 @@ module StandardId
|
|
|
225
228
|
[]
|
|
226
229
|
end
|
|
227
230
|
|
|
228
|
-
#
|
|
231
|
+
# --------------------------------------------------------------------
|
|
232
|
+
# Configuration & enablement
|
|
233
|
+
# --------------------------------------------------------------------
|
|
234
|
+
#
|
|
235
|
+
# Each entry returned by {config_schema} may carry two provider-level
|
|
236
|
+
# options in addition to the ConfigSchema ones (`type:`, `default:`).
|
|
237
|
+
# They are consumed by StandardId and never reach ConfigSchema:
|
|
238
|
+
#
|
|
239
|
+
# - `env:` — the ENV variable the field falls back to when the host app
|
|
240
|
+
# never assigns it. Defaults to the upper-cased field name
|
|
241
|
+
# (`google_client_id` → `GOOGLE_CLIENT_ID`), which is the canonical
|
|
242
|
+
# naming scheme. Pass a String to use another variable, or `false` to
|
|
243
|
+
# disable the ENV fallback for that field. Explicit configuration
|
|
244
|
+
# (`c.social.google_client_id = ...`, including an explicit `nil`)
|
|
245
|
+
# always wins over the ENV fallback.
|
|
246
|
+
# - `required: true` — the field must be present whenever the provider
|
|
247
|
+
# is {enabled?}. Missing required fields are reported by
|
|
248
|
+
# {configuration_errors} and by the boot-time check (see
|
|
249
|
+
# `c.social.provider_misconfiguration`).
|
|
250
|
+
#
|
|
251
|
+
# Both options require standard_id >= 0.42. A plugin that declares them
|
|
252
|
+
# should depend on `standard_id >= 0.42` — older versions pass the
|
|
253
|
+
# options through to ConfigSchema and raise ArgumentError.
|
|
254
|
+
#
|
|
255
|
+
# @example
|
|
256
|
+
# def self.config_schema
|
|
257
|
+
# {
|
|
258
|
+
# github_client_id: { type: :string, default: nil },
|
|
259
|
+
# github_client_secret: { type: :string, default: nil, required: true },
|
|
260
|
+
# github_enterprise_host: { type: :string, default: nil, env: false }
|
|
261
|
+
# }
|
|
262
|
+
# end
|
|
263
|
+
|
|
264
|
+
# Config field whose presence switches this provider on.
|
|
229
265
|
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
266
|
+
# Defaults to `:"<provider_name>_client_id"` when that field is part of
|
|
267
|
+
# {config_schema}, otherwise nil (a provider with no enabling field is
|
|
268
|
+
# always enabled once registered). Override when the provider keys off a
|
|
269
|
+
# different field.
|
|
234
270
|
#
|
|
235
|
-
# @return [
|
|
271
|
+
# @return [Symbol, nil]
|
|
272
|
+
def enabling_config_field
|
|
273
|
+
field = :"#{provider_name}_client_id"
|
|
274
|
+
config_schema.key?(field) ? field : nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Config fields that must be present whenever the provider is enabled.
|
|
236
278
|
#
|
|
237
|
-
|
|
238
|
-
|
|
279
|
+
# Defaults to the {config_schema} fields declared with `required: true`.
|
|
280
|
+
#
|
|
281
|
+
# @return [Array<Symbol>]
|
|
282
|
+
def required_config_fields
|
|
283
|
+
config_schema.select { |_name, options| options.is_a?(Hash) && options[:required] }.keys.map(&:to_sym)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Whether the host app has switched this provider on.
|
|
287
|
+
#
|
|
288
|
+
# True when {enabling_config_field} is present in the configuration (or
|
|
289
|
+
# when the provider has no enabling field). Says nothing about whether
|
|
290
|
+
# the rest of the configuration is complete — see {configuration_errors}
|
|
291
|
+
# and {configured?}.
|
|
292
|
+
#
|
|
293
|
+
# @return [Boolean]
|
|
294
|
+
def enabled?
|
|
295
|
+
field = enabling_config_field
|
|
296
|
+
return true if field.nil?
|
|
297
|
+
|
|
298
|
+
config_value(field).present?
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Human-readable problems with this provider's configuration.
|
|
302
|
+
#
|
|
303
|
+
# Empty when the provider is disabled: a provider nobody switched on
|
|
304
|
+
# cannot be misconfigured. Messages name fields, never their values.
|
|
305
|
+
#
|
|
306
|
+
# @return [Array<String>]
|
|
307
|
+
def configuration_errors
|
|
308
|
+
return [] unless enabled?
|
|
309
|
+
|
|
310
|
+
missing = required_config_fields.select { |field| config_value(field).blank? }
|
|
311
|
+
return [] if missing.empty?
|
|
312
|
+
|
|
313
|
+
trigger = enabling_config_field ? " when #{enabling_config_field} is set" : ""
|
|
314
|
+
missing.map { |field| "#{field} is required#{trigger}" }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Enabled and free of {configuration_errors}.
|
|
318
|
+
#
|
|
319
|
+
# @return [Boolean]
|
|
320
|
+
def configured?
|
|
321
|
+
enabled? && configuration_errors.empty?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The flow a native/API callback is running, used as `context[:flow]`
|
|
325
|
+
# for {resolve_params}.
|
|
326
|
+
#
|
|
327
|
+
# Called by the API callback endpoint (`/api/oauth/callback/:provider`).
|
|
328
|
+
# The default honours an explicit `flow=web` param only for providers
|
|
329
|
+
# that {supports_mobile_callback?} — those are the providers whose web
|
|
330
|
+
# and native flows differ (e.g. Apple's distinct Services ID vs bundle
|
|
331
|
+
# ID audiences). Everything else is treated as `:mobile`, which is what
|
|
332
|
+
# the API endpoint served before this hook existed. Override to
|
|
333
|
+
# recognise other flows.
|
|
334
|
+
#
|
|
335
|
+
# @param params [#[]] Request params
|
|
336
|
+
# @return [Symbol] `:web` or `:mobile`
|
|
337
|
+
def flow_for(params)
|
|
338
|
+
return :mobile unless supports_mobile_callback?
|
|
339
|
+
|
|
340
|
+
params[:flow].to_s.downcase == "web" ? :web : :mobile
|
|
239
341
|
end
|
|
240
342
|
|
|
241
343
|
protected
|
|
@@ -252,6 +354,118 @@ module StandardId
|
|
|
252
354
|
tokens: tokens.compact
|
|
253
355
|
}.with_indifferent_access
|
|
254
356
|
end
|
|
357
|
+
|
|
358
|
+
# Read one of this provider's config fields.
|
|
359
|
+
#
|
|
360
|
+
# Goes through the top-level `StandardId.config` accessor — the same
|
|
361
|
+
# way the provider plugins read their own credentials — so the value
|
|
362
|
+
# seen here always matches the one the provider will use.
|
|
363
|
+
#
|
|
364
|
+
# @param field [Symbol, String]
|
|
365
|
+
# @return [Object, nil]
|
|
366
|
+
def config_value(field)
|
|
367
|
+
StandardId.config.public_send(field)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Run the block, re-raising any non-OAuth error as StandardId::OAuthError.
|
|
371
|
+
#
|
|
372
|
+
# StandardId::OAuthError (and subclasses such as InvalidRequestError)
|
|
373
|
+
# propagate unchanged. Anything else — network, JSON, OpenSSL, JWT
|
|
374
|
+
# errors — is wrapped so callers only ever handle OAuthError, with the
|
|
375
|
+
# original exception kept as `cause`.
|
|
376
|
+
#
|
|
377
|
+
# @param message_prefix [String, nil] Prepended to the wrapped error's
|
|
378
|
+
# message, e.g. "Failed to fetch JWK" → "Failed to fetch JWK: <msg>"
|
|
379
|
+
# @return [Object] the block's return value
|
|
380
|
+
# @raise [StandardId::OAuthError]
|
|
381
|
+
#
|
|
382
|
+
# @example
|
|
383
|
+
# def fetch_user_info(access_token:)
|
|
384
|
+
# rescue_to_oauth_error do
|
|
385
|
+
# response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
|
|
386
|
+
# JSON.parse(response.body)
|
|
387
|
+
# end
|
|
388
|
+
# end
|
|
389
|
+
#
|
|
390
|
+
def rescue_to_oauth_error(message_prefix = nil)
|
|
391
|
+
yield
|
|
392
|
+
rescue StandardId::OAuthError
|
|
393
|
+
raise
|
|
394
|
+
rescue StandardError => e
|
|
395
|
+
message = message_prefix ? "#{message_prefix}: #{e.message}" : e.message
|
|
396
|
+
raise StandardId::OAuthError, message, cause: e
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Verify an ID token's `nonce` claim against the one the server issued.
|
|
400
|
+
#
|
|
401
|
+
# No-op when `expected` is blank (flows without a server-generated
|
|
402
|
+
# nonce). Comparison is constant-time. The error message deliberately
|
|
403
|
+
# does not include either nonce: the expected value is a server-side
|
|
404
|
+
# secret for the duration of the flow, and error messages end up in
|
|
405
|
+
# redirects, logs and error trackers.
|
|
406
|
+
#
|
|
407
|
+
# @param expected [String, nil] Nonce stored when the flow started
|
|
408
|
+
# @param actual [String, nil] `nonce` claim from the verified ID token
|
|
409
|
+
# @return [void]
|
|
410
|
+
# @raise [StandardId::InvalidRequestError] on mismatch
|
|
411
|
+
def verify_nonce!(expected:, actual:)
|
|
412
|
+
return if expected.blank?
|
|
413
|
+
return if actual.is_a?(String) && ActiveSupport::SecurityUtils.secure_compare(actual, expected.to_s)
|
|
414
|
+
|
|
415
|
+
raise StandardId::InvalidRequestError, "ID token nonce mismatch"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Build an OAuth 2.0 authorization-code URL.
|
|
419
|
+
#
|
|
420
|
+
# Emits `client_id`, `redirect_uri`, `response_type`, `state`, then one
|
|
421
|
+
# entry per {supported_authorization_params}, taking the caller's value
|
|
422
|
+
# from `options` or falling back to `defaults`. Nil values are dropped.
|
|
423
|
+
#
|
|
424
|
+
# @param endpoint [String] Provider authorization endpoint
|
|
425
|
+
# @param client_id [String]
|
|
426
|
+
# @param redirect_uri [String]
|
|
427
|
+
# @param state [String]
|
|
428
|
+
# @param options [Hash] Caller-supplied authorization params
|
|
429
|
+
# @param defaults [Hash] Per-param fallbacks (e.g. `{ scope: "openid email" }`)
|
|
430
|
+
# @param response_type [String]
|
|
431
|
+
# @return [String]
|
|
432
|
+
#
|
|
433
|
+
# @example
|
|
434
|
+
# def self.authorization_url(state:, redirect_uri:, **options)
|
|
435
|
+
# build_authorization_url(
|
|
436
|
+
# endpoint: AUTH_ENDPOINT,
|
|
437
|
+
# client_id: StandardId.config.github_client_id,
|
|
438
|
+
# redirect_uri:, state:, options:,
|
|
439
|
+
# defaults: { scope: DEFAULT_SCOPE }
|
|
440
|
+
# )
|
|
441
|
+
# end
|
|
442
|
+
#
|
|
443
|
+
def build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code")
|
|
444
|
+
query = {
|
|
445
|
+
client_id: client_id,
|
|
446
|
+
redirect_uri: redirect_uri,
|
|
447
|
+
response_type: response_type,
|
|
448
|
+
state: state
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
supported_authorization_params.each do |param|
|
|
452
|
+
query[param] = options[param] || defaults[param]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
"#{endpoint}?#{URI.encode_www_form(query.compact)}"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Pick the standard tokens out of a token-endpoint response.
|
|
459
|
+
#
|
|
460
|
+
# @param parsed_token [Hash] Parsed token response (String or Symbol keys)
|
|
461
|
+
# @return [Hash{Symbol => String}] `access_token`, `refresh_token` and
|
|
462
|
+
# `id_token`, nil entries removed
|
|
463
|
+
def extract_tokens(parsed_token)
|
|
464
|
+
%i[access_token refresh_token id_token].each_with_object({}) do |key, tokens|
|
|
465
|
+
value = parsed_token[key.to_s] || parsed_token[key]
|
|
466
|
+
tokens[key] = value unless value.nil?
|
|
467
|
+
end
|
|
468
|
+
end
|
|
255
469
|
end
|
|
256
470
|
end
|
|
257
471
|
end
|