standard_id 0.15.0 → 0.16.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -1
  3. data/app/controllers/concerns/standard_id/audience_verification.rb +23 -3
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +20 -5
  5. data/app/controllers/concerns/standard_id/set_current_request_details.rb +33 -0
  6. data/app/controllers/concerns/standard_id/social_authentication.rb +19 -0
  7. data/app/controllers/standard_id/api/oauth/revocations_controller.rb +55 -6
  8. data/app/controllers/standard_id/api/sessions_controller.rb +7 -3
  9. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +6 -0
  10. data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -1
  11. data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -7
  12. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -7
  13. data/app/forms/standard_id/web/reset_password_start_form.rb +20 -19
  14. data/app/jobs/standard_id/password_reset_delivery_job.rb +42 -0
  15. data/app/mailers/standard_id/password_reset_mailer.rb +16 -0
  16. data/app/models/concerns/standard_id/account_associations.rb +43 -0
  17. data/app/models/standard_id/authorization_code.rb +15 -2
  18. data/app/models/standard_id/client_application.rb +78 -3
  19. data/app/models/standard_id/refresh_token.rb +23 -27
  20. data/app/models/standard_id/session.rb +8 -1
  21. data/app/views/standard_id/password_reset_mailer/reset_email.html.erb +27 -0
  22. data/app/views/standard_id/password_reset_mailer/reset_email.text.erb +12 -0
  23. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +117 -0
  24. data/lib/generators/standard_id/install/templates/standard_id.rb +15 -1
  25. data/lib/standard_id/account_locking.rb +23 -10
  26. data/lib/standard_id/account_status.rb +22 -8
  27. data/lib/standard_id/api/authentication_guard.rb +26 -18
  28. data/lib/standard_id/api/token_manager.rb +21 -1
  29. data/lib/standard_id/api_engine.rb +2 -0
  30. data/lib/standard_id/config/callable_validator.rb +163 -0
  31. data/lib/standard_id/config/schema.rb +61 -1
  32. data/lib/standard_id/config/scope_claims_validator.rb +47 -0
  33. data/lib/standard_id/engine.rb +60 -1
  34. data/lib/standard_id/errors.rb +3 -5
  35. data/lib/standard_id/events/definitions.rb +5 -2
  36. data/lib/standard_id/events/subscribers/password_reset_delivery_subscriber.rb +36 -0
  37. data/lib/standard_id/jwt_service.rb +43 -3
  38. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +10 -0
  39. data/lib/standard_id/oauth/authorization_flow.rb +8 -0
  40. data/lib/standard_id/otp.rb +1 -1
  41. data/lib/standard_id/passwordless/base_strategy.rb +9 -3
  42. data/lib/standard_id/passwordless/verification_service.rb +94 -71
  43. data/lib/standard_id/passwordless.rb +34 -2
  44. data/lib/standard_id/version.rb +1 -1
  45. data/lib/standard_id/web_engine.rb +2 -0
  46. data/lib/standard_id.rb +1 -0
  47. data/lib/tasks/standard_id_tasks.rake +34 -8
  48. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7231e9470123ff19809e493c1ebea4f84b573b3a39ca1a1f79f1e186a378fa1
4
- data.tar.gz: 9dcc82b759903b33a2352e3849ff9044ca576b400e75f728f6c4bf49317011cd
3
+ metadata.gz: 1cc415579c94e298230fc9d647869d7c2ea2b422b1130eac38bd05bba262956a
4
+ data.tar.gz: 653d1cd02170d5ba6933973635cb50d62729be158b0037812da8b2f832fb0c0e
5
5
  SHA512:
6
- metadata.gz: 933f67b55905bd7b6ff421a77ff8b51ea3eab24ce84ed939e710b657b5e3838ead43ce0e0f2e9cdbfb58afb04f1d3f91e69e34ce335e13fdbccfd7161ec63600
7
- data.tar.gz: 789d75aa20a6ff0305aa75140abc85d6dd4c7b7ffa42f85f06d3828429c1c763544359dc0519fc4c2ca8debdfa5e568411820b6a4846e86d2990a0b43c272101
6
+ metadata.gz: 16fa94e1dde5fa46e92999ef2fb24b5bb683ff5f5ab3e6549e91f8c738e4cdd42d8e872ab0d13705659ec6ab2bb4c4e184f4947c1d95bf5e00afbc3b9dfe0328
7
+ data.tar.gz: b730ef767275f8d71aa9b93a8c8fca55a1935c84e1e4e6eaf022ec935d2d152d7b72e99576b5075e3dffba8269b07be66abf43f4fd96e1f7728858d64244f7e2
data/README.md CHANGED
@@ -529,6 +529,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
529
529
  | | `social.account.created` | `account`, `provider`, `social_info` | When a social login creates a new account |
530
530
  | | `social.account.linked` | `account`, `provider`, `identifier` | When a social identity links to an existing account |
531
531
  | | `social.auth.completed` | `account`, `provider`, `tokens` | After social login completes |
532
+ | | `social.auth.failed` | `provider`, `error`, `error_class`, `account` | When social login fails due to an infrastructure error (HTTP/DNS/SSL/timeout) |
532
533
  | Credential | `credential.password.created` | `credential`, `account` | After a password credential is created |
533
534
  | | `credential.password.reset_initiated` | `credential`, `account`, `reset_token_expires_at` | After a password reset is initiated |
534
535
  | | `credential.password.reset_completed` | `credential`, `account` | After a password reset is confirmed |
@@ -1109,9 +1110,17 @@ bundle exec rspec spec/controllers/
1109
1110
  - CSRF protection enabled for web requests
1110
1111
  - Secure session management with proper expiry
1111
1112
  - Client secrets are rotatable with audit trail
1112
- - PKCE support for public clients
1113
+ - PKCE is enforced whenever `ClientApplication#require_pkce?` is true (the
1114
+ default). Public clients cannot disable it — a model-level validation
1115
+ rejects any public client saved with `require_pkce: false`. Authorize
1116
+ requests that omit `code_challenge` for a PKCE-required client are
1117
+ rejected with `invalid_request`.
1113
1118
  - Rate limiting on authentication endpoints
1114
1119
 
1120
+ ## Scheduled Maintenance
1121
+
1122
+ StandardId ships cleanup jobs (`StandardId::CleanupExpiredSessionsJob`, `StandardId::CleanupExpiredRefreshTokensJob`) and rake wrappers (`standard_id:cleanup:all`, `:sessions`, `:refresh_tokens`) to prune expired rows. See [docs/OPERATIONS.md](docs/OPERATIONS.md) for SolidQueue, sidekiq-cron, whenever, and system-cron scheduling examples.
1123
+
1115
1124
  ## Contributing
1116
1125
 
1117
1126
  1. Fork the repository
@@ -3,9 +3,29 @@
3
3
  module StandardId
4
4
  # Per-controller audience verification for API endpoints.
5
5
  #
6
- # While StandardId validates that the JWT `aud` claim is in the global
7
- # `allowed_audiences` list, this concern provides additional defense-in-depth
8
- # by restricting which audiences are accepted by each controller.
6
+ # StandardId enforces audience in three layers:
7
+ #
8
+ # 1. At encode time (`Oauth::TokenGrantFlow#validate_audience!`):
9
+ # rejects issuance of tokens with an audience outside the global
10
+ # `StandardId.config.oauth.allowed_audiences` list.
11
+ # 2. At decode time (`JwtService.decode(..., allowed_audiences:)`):
12
+ # rejects tokens whose `aud` claim does not match the caller-supplied
13
+ # list, raising `StandardId::InvalidAudienceError`. The engine's
14
+ # `Api::TokenManager#verify_jwt_token` wires this automatically when
15
+ # `StandardId.config.oauth.allowed_audiences` is non-empty — a
16
+ # mismatch there is normalised to the same 401 "invalid token"
17
+ # response as a bad signature. Call sites that pass no arguments
18
+ # to `decode` directly still skip aud checks by design.
19
+ # 3. At the controller, via this concern: layers on top as
20
+ # per-endpoint defense-in-depth. Required when a controller serves
21
+ # a strict subset of the global allowed audiences (e.g., the global
22
+ # list is `%w[web api admin]` but `AdminController` must only
23
+ # accept `admin`).
24
+ #
25
+ # With the global decode-time check now automatic, this concern is
26
+ # primarily useful for tightening the allowed audience per controller,
27
+ # not for plugging the "controller forgot to verify aud" gap (which
28
+ # is closed globally when `config.oauth.allowed_audiences` is set).
9
29
  #
10
30
  # In addition, when `StandardId.config.oauth.audience_profile_types` is set,
11
31
  # this concern enforces the audience → profile-type binding: after the
@@ -144,12 +144,27 @@ module StandardId
144
144
  end
145
145
 
146
146
  # Determine if this is the account's first browser session.
147
- # When called before session creation (before_sign_in), count == 0 means first.
148
- # When called after session creation (after_sign_in), count <= 1 means first
149
- # (the just-created session is the only one).
147
+ # Uses `exists?` (which compiles to `SELECT 1 ... LIMIT 1`) instead of
148
+ # `count` we only care whether *any other* active browser session is
149
+ # present, not the exact number. This short-circuits as soon as a row is
150
+ # found, so it's dramatically cheaper on accounts with many sessions.
151
+ #
152
+ # When called before session creation (before_sign_in), "first" means no
153
+ # active browser session exists at all.
154
+ # When called after session creation (after_sign_in), the just-created
155
+ # session counts as one, so "first" means no OTHER active browser session
156
+ # exists — i.e. exclude the current session before checking existence.
157
+ #
158
+ # Invariant: when `session_created: true`, `session_manager.current_session`
159
+ # is always set — invoke_after_sign_in runs immediately after
160
+ # session_manager.sign_in_account, which populates current_session. The
161
+ # nil guard below is defensive only; it would behave differently from the
162
+ # old `count <= 1` path (new: false, old: true) if that invariant were
163
+ # ever violated, but today no call site can reach it.
150
164
  def first_sign_in?(account, session_created: true)
151
- active_count = account.sessions.where(type: "StandardId::BrowserSession").active.count
152
- session_created ? active_count <= 1 : active_count == 0
165
+ scope = account.sessions.where(type: "StandardId::BrowserSession").active
166
+ scope = scope.where.not(id: session_manager.current_session.id) if session_created && session_manager.current_session
167
+ !scope.exists?
153
168
  end
154
169
 
155
170
  # Handle AuthenticationDenied by revoking the session and redirecting to login.
@@ -4,6 +4,7 @@ module StandardId
4
4
 
5
5
  included do
6
6
  before_action :set_current_request_details
7
+ after_action :clear_rails_event_context
7
8
  end
8
9
 
9
10
  private
@@ -14,6 +15,38 @@ module StandardId
14
15
  ::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
15
16
  ::Current.ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip) if ::Current.respond_to?(:ip_address=)
16
17
  ::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
18
+
19
+ set_rails_event_context
20
+ end
21
+
22
+ # Mirror request details into the Rails 8.1+ structured event reporter so
23
+ # that `Rails.event.notify` calls made during this request automatically
24
+ # carry request_id / ip_address / user_agent. Feature-detected: on older
25
+ # Rails versions this is a no-op. Reads straight from `::Current` — setters
26
+ # and getters on `ActiveSupport::CurrentAttributes` are paired, so the
27
+ # `respond_to?(:foo=)` checks above also guarantee the getter exists.
28
+ def set_rails_event_context
29
+ return unless defined?(::Current) && rails_event_available?
30
+
31
+ Rails.event.set_context(
32
+ request_id: (::Current.request_id if ::Current.respond_to?(:request_id)),
33
+ ip_address: (::Current.ip_address if ::Current.respond_to?(:ip_address)),
34
+ user_agent: (::Current.user_agent if ::Current.respond_to?(:user_agent))
35
+ )
36
+ end
37
+
38
+ # Rails 8.1 clears fiber-local state between requests via middleware, but
39
+ # thread-pooled servers (Puma, Falcon) can reuse the same fiber across
40
+ # requests. An explicit clear ensures a denied-upstream value cannot leak
41
+ # into the next request handled by the same worker.
42
+ def clear_rails_event_context
43
+ return unless rails_event_available?
44
+
45
+ Rails.event.clear_context
46
+ end
47
+
48
+ def rails_event_available?
49
+ Rails.respond_to?(:event) && Rails.event.respond_to?(:set_context)
17
50
  end
18
51
  end
19
52
  end
@@ -200,5 +200,24 @@ module StandardId
200
200
  source: "social"
201
201
  )
202
202
  end
203
+
204
+ # Emit SOCIAL_AUTH_FAILED for infrastructure-level failures during the
205
+ # social authentication flow (HTTP errors, DNS/SSL/timeouts surfaced as
206
+ # OAuthError by provider implementations).
207
+ #
208
+ # Host apps can subscribe to this event to forward failures to Sentry or
209
+ # similar observability tools without monkey-patching the controller.
210
+ #
211
+ # @param error [StandardId::OAuthError] the captured failure
212
+ # @param account [Object, nil] the account if one was resolved before the failure
213
+ def emit_social_auth_failed(error, account: nil)
214
+ StandardId::Events.publish(
215
+ StandardId::Events::SOCIAL_AUTH_FAILED,
216
+ provider: provider&.provider_name,
217
+ error: error.message,
218
+ error_class: error.class.name,
219
+ account: account
220
+ )
221
+ end
203
222
  end
204
223
  end
@@ -29,15 +29,64 @@ module StandardId
29
29
  # revocation via sub claim regardless of token type (RFC 7009 §2.1)
30
30
  revoked_sessions = sessions.to_a
31
31
  if revoked_sessions.any?
32
+ now = Time.current
33
+ session_ids = revoked_sessions.map(&:id)
34
+
35
+ # Bulk-revoke in two queries (one UPDATE per table) instead of
36
+ # issuing session.revoke! per row, which would be O(N) UPDATEs plus
37
+ # another O(N) cascades to refresh_tokens.
38
+ #
39
+ # Tradeoff: update_all skips ActiveRecord callbacks, so the per-row
40
+ # SESSION_REVOKED event emitted by Session#revoke! is not fired
41
+ # automatically. We re-emit it explicitly below so audit-trail
42
+ # subscribers (account status/locking, etc.) still see one event
43
+ # per revoked session — the semantics are preserved, only the SQL
44
+ # shape has changed.
32
45
  ActiveRecord::Base.transaction do
33
- revoked_sessions.each { |session| session.revoke!(reason: "token_revocation") }
46
+ StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
47
+ StandardId::RefreshToken
48
+ .where(session_id: session_ids, revoked_at: nil)
49
+ .update_all(revoked_at: now)
50
+ end
51
+
52
+ # DB state is already committed above; event publishing is best-effort
53
+ # audit emission. A failing subscriber must not short-circuit the loop
54
+ # and leave later sessions without their SESSION_REVOKED event, which
55
+ # would permanently desync audit-trail consumers from the DB.
56
+ #
57
+ # All revoked_sessions share the same account_id (we filtered by it
58
+ # at line 25), so we load the account once rather than calling
59
+ # session.account per row, which would issue N extra SELECTs.
60
+ shared_account = revoked_sessions.first.account
61
+ revoked_sessions.each do |session|
62
+ session.revoked_at = now
63
+ begin
64
+ StandardId::Events.publish(
65
+ StandardId::Events::SESSION_REVOKED,
66
+ session: session,
67
+ account: shared_account,
68
+ reason: "token_revocation"
69
+ )
70
+ rescue StandardError => e
71
+ StandardId.logger.error(
72
+ "[StandardId::Revocations] Failed to publish SESSION_REVOKED " \
73
+ "for session #{session.id}: #{e.class}: #{e.message}"
74
+ )
75
+ end
34
76
  end
35
77
 
36
- StandardId::Events.publish(
37
- StandardId::Events::OAUTH_TOKEN_REVOKED,
38
- account_id: account_id,
39
- sessions_revoked: revoked_sessions.size
40
- )
78
+ begin
79
+ StandardId::Events.publish(
80
+ StandardId::Events::OAUTH_TOKEN_REVOKED,
81
+ account_id: account_id,
82
+ sessions_revoked: revoked_sessions.size
83
+ )
84
+ rescue StandardError => e
85
+ StandardId.logger.error(
86
+ "[StandardId::Revocations] Failed to publish OAUTH_TOKEN_REVOKED " \
87
+ "for account #{account_id}: #{e.class}: #{e.message}"
88
+ )
89
+ end
41
90
  end
42
91
 
43
92
  head :ok
@@ -26,15 +26,19 @@ module StandardId
26
26
 
27
27
  private
28
28
 
29
+ # All Session subclasses live on the same STI table and therefore
30
+ # always respond to these columns — the prior `respond_to?` guards
31
+ # were defensive overhead that allocated per record. Direct access
32
+ # is both cheaper and clearer.
29
33
  def serialize_session(session)
30
34
  {
31
35
  id: session.id,
32
36
  type: session.type&.demodulize,
33
37
  created_at: session.created_at.iso8601,
34
- last_refreshed_at: session.respond_to?(:last_refreshed_at) ? session.last_refreshed_at&.iso8601 : nil,
35
- ip_address: session.respond_to?(:ip_address) ? session.ip_address : nil,
38
+ last_refreshed_at: session.last_refreshed_at&.iso8601,
39
+ ip_address: session.ip_address,
36
40
  # user_agent is the API-facing name for the device_agent model attribute
37
- user_agent: session.respond_to?(:device_agent) ? session.device_agent : nil
41
+ user_agent: session.device_agent
38
42
  }.compact
39
43
  end
40
44
  end
@@ -61,7 +61,13 @@ module StandardId
61
61
  redirect_to destination, redirect_options
62
62
  rescue StandardId::AuthenticationDenied => e
63
63
  handle_authentication_denied(e, account: account, newly_created: newly_created)
64
+ rescue StandardId::SocialLinkError => e
65
+ # Policy/link error — SOCIAL_LINK_BLOCKED has already been emitted
66
+ # by validate_social_link!, so do not also emit SOCIAL_AUTH_FAILED
67
+ # (which is reserved for infrastructure-level failures).
68
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
64
69
  rescue StandardId::OAuthError => e
70
+ emit_social_auth_failed(e, account: account)
65
71
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
66
72
  end
67
73
  end
@@ -14,7 +14,10 @@ module StandardId
14
14
  end
15
15
 
16
16
  def create
17
- form = StandardId::Web::ResetPasswordStartForm.new(email: params[:email])
17
+ form = StandardId::Web::ResetPasswordStartForm.new(
18
+ email: params[:email],
19
+ reset_url_template: build_reset_url_template
20
+ )
18
21
 
19
22
  if form.submit
20
23
  flash[:notice] = "If an account with that email exists, we've sent password reset instructions."
@@ -24,6 +27,29 @@ module StandardId
24
27
  render :show, status: :unprocessable_content
25
28
  end
26
29
  end
30
+
31
+ private
32
+
33
+ # Build a password-reset URL template containing a literal "{token}"
34
+ # placeholder. The async delivery job substitutes the placeholder with
35
+ # the generated token once the account lookup completes. We build this
36
+ # here (rather than in the job) so the URL reflects the request's
37
+ # scheme/host/port — the job has no access to the HTTP request.
38
+ #
39
+ # The route helper may be absent if the host app mounts the engine
40
+ # without the `:password_reset` mechanism, or raise UrlGenerationError
41
+ # if required params are missing; fall back to a request-derived URL
42
+ # in those cases. Any other exception should surface normally.
43
+ def build_reset_url_template
44
+ base = begin
45
+ reset_password_confirm_url
46
+ rescue NameError, NoMethodError, ActionController::UrlGenerationError
47
+ nil
48
+ end
49
+ base ||= "#{request.base_url}/reset_password/confirm"
50
+ separator = base.include?("?") ? "&" : "?"
51
+ "#{base}#{separator}token={token}"
52
+ end
27
53
  end
28
54
  end
29
55
  end
@@ -32,7 +32,7 @@ module StandardId
32
32
  realm: "verification",
33
33
  channel: "email",
34
34
  target: email,
35
- code: generate_otp_code,
35
+ code: StandardId::Passwordless.generate_otp_code,
36
36
  expires_at: 10.minutes.from_now,
37
37
  ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
38
38
  user_agent: request.user_agent
@@ -42,12 +42,6 @@ module StandardId
42
42
 
43
43
  redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
44
44
  end
45
-
46
- private
47
-
48
- def generate_otp_code
49
- (SecureRandom.random_number(900_000) + 100_000).to_s
50
- end
51
45
  end
52
46
  end
53
47
  end
@@ -32,7 +32,7 @@ module StandardId
32
32
  realm: "verification",
33
33
  channel: "sms",
34
34
  target: phone,
35
- code: generate_otp_code,
35
+ code: StandardId::Passwordless.generate_otp_code,
36
36
  expires_at: 10.minutes.from_now,
37
37
  ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
38
38
  user_agent: request.user_agent
@@ -42,12 +42,6 @@ module StandardId
42
42
 
43
43
  redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
44
44
  end
45
-
46
- private
47
-
48
- def generate_otp_code
49
- (SecureRandom.random_number(900_000) + 100_000).to_s
50
- end
51
45
  end
52
46
  end
53
47
  end
@@ -6,32 +6,33 @@ module StandardId
6
6
 
7
7
  attribute :email, :string
8
8
 
9
- attr_reader :password_credential, :token
10
-
11
9
  validates :email, presence: { message: "Please enter your email address" }, format: { with: URI::MailTo::EMAIL_REGEXP }
12
10
 
13
- def submit
14
- return false unless valid?
15
-
16
- if token.present?
17
- # TODO: send reset link via email
18
- end
19
-
20
- true
11
+ # Constructor accepts the reset URL template so the form is decoupled
12
+ # from routing. The controller builds a URL from `reset_password_confirm_url`
13
+ # (or a request-derived fallback) and appends a literal `?token={token}` (or
14
+ # `&token={token}`) marker via string concatenation. The delivery job
15
+ # substitutes that placeholder with the actual token after account lookup.
16
+ def initialize(attributes = {})
17
+ @reset_url_template = attributes.delete(:reset_url_template) if attributes.is_a?(Hash)
18
+ super
21
19
  end
22
20
 
23
- def password_credential
24
- @password_credential ||= identifier&.account&.credentials&.where(credentialable_type: "StandardId::PasswordCredential")&.sole&.credentialable
25
- end
21
+ attr_reader :reset_url_template
26
22
 
27
- def token
28
- @token ||= password_credential&.generate_token_for(:password_reset)
29
- end
23
+ def submit
24
+ return false unless valid?
30
25
 
31
- private
26
+ # Enqueue the full lookup + token generation + mailer delivery pipeline
27
+ # so the controller response time does not depend on whether an account
28
+ # exists for the submitted email. This closes the user-enumeration
29
+ # timing side channel.
30
+ StandardId::PasswordResetDeliveryJob.perform_later(
31
+ email: email.to_s,
32
+ reset_url_template: reset_url_template.to_s
33
+ )
32
34
 
33
- def identifier
34
- @identifier ||= StandardId::EmailIdentifier.find_by(value: email.to_s.strip.downcase)
35
+ true
35
36
  end
36
37
  end
37
38
  end
@@ -0,0 +1,42 @@
1
+ module StandardId
2
+ # Handles the full password-reset email delivery pipeline asynchronously.
3
+ #
4
+ # Running this work in a job (rather than inline in the request) eliminates
5
+ # the timing side-channel that would otherwise leak whether an account
6
+ # exists for a given email: every request enqueues the same job, so the
7
+ # synchronous request path is constant-time regardless of account state.
8
+ class PasswordResetDeliveryJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ # @param email [String] the raw email submitted by the user
12
+ # @param reset_url_template [String] URL with a literal "{token}" placeholder
13
+ # that the job will substitute with the generated reset token
14
+ def perform(email:, reset_url_template:)
15
+ normalized = email.to_s.strip.downcase
16
+ return if normalized.blank?
17
+
18
+ identifier = StandardId::EmailIdentifier.find_by(value: normalized)
19
+ return if identifier.nil?
20
+
21
+ password_credential = identifier.account
22
+ &.credentials
23
+ &.where(credentialable_type: "StandardId::PasswordCredential")
24
+ &.first
25
+ &.credentialable
26
+ return if password_credential.nil?
27
+
28
+ token = password_credential.generate_token_for(:password_reset)
29
+ return if token.blank?
30
+
31
+ reset_url = reset_url_template.to_s.sub("{token}", token)
32
+
33
+ StandardId::Events.publish(
34
+ StandardId::Events::CREDENTIAL_PASSWORD_RESET_INITIATED,
35
+ account: identifier.account,
36
+ identifier: normalized,
37
+ token: token,
38
+ reset_url: reset_url
39
+ )
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ module StandardId
2
+ class PasswordResetMailer < ApplicationMailer
3
+ layout false
4
+
5
+ def reset_email
6
+ @reset_url = params[:reset_url]
7
+ @email = params[:email]
8
+
9
+ mail(
10
+ to: @email,
11
+ from: StandardId.config.reset_password.mailer_from,
12
+ subject: StandardId.config.reset_password.mailer_subject
13
+ )
14
+ end
15
+ end
16
+ end
@@ -12,6 +12,31 @@ module StandardId
12
12
  accepts_nested_attributes_for :identifiers
13
13
  end
14
14
 
15
+ # Returns the account's StandardId::EmailIdentifier, if any.
16
+ #
17
+ # Uses the in-memory collection when identifiers is already loaded to
18
+ # avoid issuing an extra query (N+1 safety). Falls back to a scoped
19
+ # query otherwise.
20
+ #
21
+ # @return [StandardId::EmailIdentifier, nil]
22
+ def email_identifier
23
+ typed_identifier(StandardId::EmailIdentifier)
24
+ end
25
+
26
+ # Returns the account's StandardId::PhoneNumberIdentifier, if any.
27
+ #
28
+ # @return [StandardId::PhoneNumberIdentifier, nil]
29
+ def phone_number_identifier
30
+ typed_identifier(StandardId::PhoneNumberIdentifier)
31
+ end
32
+
33
+ # Returns the account's StandardId::UsernameIdentifier, if any.
34
+ #
35
+ # @return [StandardId::UsernameIdentifier, nil]
36
+ def username_identifier
37
+ typed_identifier(StandardId::UsernameIdentifier)
38
+ end
39
+
15
40
  class_methods do
16
41
  def find_or_create_by_verified_email!(email, **account_attributes)
17
42
  raise ArgumentError, "email is required" if email.blank?
@@ -54,5 +79,23 @@ module StandardId
54
79
  identifier.account
55
80
  end
56
81
  end
82
+
83
+ private
84
+
85
+ # Fetch the first identifier of the given STI subclass.
86
+ #
87
+ # When the identifiers association is already loaded, filters in memory
88
+ # to avoid triggering an additional query. Otherwise issues a scoped
89
+ # query that returns at most one row.
90
+ #
91
+ # @param klass [Class] subclass of StandardId::Identifier
92
+ # @return [StandardId::Identifier, nil]
93
+ def typed_identifier(klass)
94
+ if association(:identifiers).loaded?
95
+ identifiers.detect { |i| i.is_a?(klass) }
96
+ else
97
+ identifiers.where(type: klass.sti_name).first
98
+ end
99
+ end
57
100
  end
58
101
  end
@@ -18,9 +18,22 @@ module StandardId
18
18
 
19
19
  def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {})
20
20
  # Fail fast: reject unsupported PKCE methods at issuance rather than
21
- # storing a code that will always fail at redemption time.
21
+ # storing a code that will always fail at redemption time. When the
22
+ # client record has PKCE required, defer to the client's configured
23
+ # `code_challenge_methods`. In all other cases (client lookup missing,
24
+ # or client opted out of PKCE but still sent a challenge) fall back
25
+ # to an S256-only belt-and-suspenders check so we never silently
26
+ # accept a weaker method.
22
27
  if code_challenge.present?
23
- unless code_challenge_method.to_s.downcase == "s256"
28
+ client = StandardId::ClientApplication.find_by(client_id: client_id)
29
+ method_supported =
30
+ if client&.require_pkce?
31
+ client.supports_pkce_method?(code_challenge_method)
32
+ else
33
+ code_challenge_method.to_s.downcase == "s256"
34
+ end
35
+
36
+ unless method_supported
24
37
  raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed"
25
38
  end
26
39
  end