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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  3. data/README.md +251 -13
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +13 -3
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  8. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  9. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  10. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  11. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  12. data/app/models/standard_id/application_record.rb +26 -0
  13. data/app/models/standard_id/authorization_code.rb +3 -1
  14. data/app/models/standard_id/client_application.rb +11 -0
  15. data/app/models/standard_id/identifier.rb +1 -0
  16. data/app/models/standard_id/session.rb +2 -1
  17. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  18. data/app/views/standard_id/web/login/show.html.erb +5 -5
  19. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  20. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  21. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  22. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  23. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  24. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  25. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  26. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  27. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  28. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  29. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  30. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  31. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  32. data/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
  33. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  34. data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
  35. data/lib/standard_id/checks/migrations.rb +61 -0
  36. data/lib/standard_id/config/schema.rb +40 -10
  37. data/lib/standard_id/config_schema.rb +28 -5
  38. data/lib/standard_id/deprecator.rb +17 -0
  39. data/lib/standard_id/engine.rb +21 -0
  40. data/lib/standard_id/instrumentation.rb +49 -0
  41. data/lib/standard_id/migration_check.rb +183 -0
  42. data/lib/standard_id/migration_helpers.rb +65 -0
  43. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  44. data/lib/standard_id/oauth/oauth_session_persistence.rb +66 -24
  45. data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
  46. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  47. data/lib/standard_id/passwordless.rb +13 -1
  48. data/lib/standard_id/provider_registry.rb +113 -2
  49. data/lib/standard_id/providers/base.rb +222 -8
  50. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  51. data/lib/standard_id/scope_config.rb +24 -7
  52. data/lib/standard_id/testing/provider_examples.rb +117 -0
  53. data/lib/standard_id/testing.rb +1 -0
  54. data/lib/standard_id/version.rb +1 -1
  55. data/lib/standard_id.rb +26 -0
  56. metadata +24 -17
  57. 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. There's no
46
- # DB-level unique constraint on (account_id, device_id), so a raw
47
- # find_by + create! would TOCTOU-race two concurrent token requests
48
- # for the same device into two duplicate rows. We acquire a SELECT
49
- # ... FOR UPDATE on the account row to serialize — account.with_lock
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 = StandardId::DeviceSession.find_by(account: account, device_id: device_id)
57
- if existing
58
- existing.update!(
59
- expires_at: StandardId::DeviceSession.expiry,
60
- ip_address: ip_address || existing.ip_address,
61
- device_agent: user_agent || existing.device_agent
62
- )
63
- existing
64
- else
65
- StandardId::DeviceSession.create!(
66
- account: account,
67
- device_id: device_id,
68
- device_agent: user_agent.presence || "OAuth:#{grant_type}",
69
- ip_address: ip_address || "0.0.0.0",
70
- expires_at: StandardId::DeviceSession.expiry
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
- authenticate!
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
- successor = StandardId::RefreshToken.find_by(previous_token_id: revoked_record.id)
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
- authenticate!
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
- enforce_audience_profile_binding!
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 : 5
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.setup if provider_class.respond_to?(:setup)
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
- StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **options)
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
- # Optional setup hook called when provider is registered.
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
- # Override this method to perform initialization tasks like:
231
- # - Registering additional routes
232
- # - Adding custom validations
233
- # - Setting up caching for JWKS
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 [void]
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
- def setup
238
- # Override in subclasses if needed
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