standard_id 0.15.0 → 0.16.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 (43) 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/social_authentication.rb +19 -0
  6. data/app/controllers/standard_id/api/oauth/revocations_controller.rb +55 -6
  7. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +6 -0
  8. data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -1
  9. data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -7
  10. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -7
  11. data/app/forms/standard_id/web/reset_password_start_form.rb +20 -19
  12. data/app/jobs/standard_id/password_reset_delivery_job.rb +42 -0
  13. data/app/mailers/standard_id/password_reset_mailer.rb +16 -0
  14. data/app/models/concerns/standard_id/account_associations.rb +43 -0
  15. data/app/models/standard_id/authorization_code.rb +15 -2
  16. data/app/models/standard_id/client_application.rb +78 -3
  17. data/app/views/standard_id/password_reset_mailer/reset_email.html.erb +27 -0
  18. data/app/views/standard_id/password_reset_mailer/reset_email.text.erb +12 -0
  19. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +117 -0
  20. data/lib/generators/standard_id/install/templates/standard_id.rb +15 -1
  21. data/lib/standard_id/account_locking.rb +23 -10
  22. data/lib/standard_id/account_status.rb +22 -8
  23. data/lib/standard_id/api/token_manager.rb +21 -1
  24. data/lib/standard_id/api_engine.rb +2 -0
  25. data/lib/standard_id/config/callable_validator.rb +163 -0
  26. data/lib/standard_id/config/schema.rb +47 -1
  27. data/lib/standard_id/config/scope_claims_validator.rb +47 -0
  28. data/lib/standard_id/engine.rb +60 -1
  29. data/lib/standard_id/errors.rb +3 -5
  30. data/lib/standard_id/events/definitions.rb +5 -2
  31. data/lib/standard_id/events/subscribers/password_reset_delivery_subscriber.rb +36 -0
  32. data/lib/standard_id/jwt_service.rb +43 -3
  33. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +10 -0
  34. data/lib/standard_id/oauth/authorization_flow.rb +8 -0
  35. data/lib/standard_id/otp.rb +1 -1
  36. data/lib/standard_id/passwordless/base_strategy.rb +9 -3
  37. data/lib/standard_id/passwordless/verification_service.rb +94 -71
  38. data/lib/standard_id/passwordless.rb +34 -2
  39. data/lib/standard_id/version.rb +1 -1
  40. data/lib/standard_id/web_engine.rb +2 -0
  41. data/lib/standard_id.rb +1 -0
  42. data/lib/tasks/standard_id_tasks.rake +34 -8
  43. 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: 9e4d6c462fcdc585b2eefb2c5224e92c7b2a9fe276c2a41ccbfd6bf7b1fee162
4
+ data.tar.gz: 91dde301a0e98b0ec60f704ff5beb03706f5f1dbf36ebdfddfc3e21cf322f5ea
5
5
  SHA512:
6
- metadata.gz: 933f67b55905bd7b6ff421a77ff8b51ea3eab24ce84ed939e710b657b5e3838ead43ce0e0f2e9cdbfb58afb04f1d3f91e69e34ce335e13fdbccfd7161ec63600
7
- data.tar.gz: 789d75aa20a6ff0305aa75140abc85d6dd4c7b7ffa42f85f06d3828429c1c763544359dc0519fc4c2ca8debdfa5e568411820b6a4846e86d2990a0b43c272101
6
+ metadata.gz: f01caba2626cf9e4c8f4950aeeece214277492d74701f6359268da79cdf4234853f86a5490d33ab250dfe2cdda04e73c9c86a2896dc7ff76e3ab36e529da3cce
7
+ data.tar.gz: 519d061ab666d5d7a197819545bc9dcd5c304946074b80eaf89dca57898e7c24d6ccbde1b94155095e2ca382340b9179a62c2d2791331345d572ab826b88c8c7
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.
@@ -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
@@ -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
@@ -12,6 +12,7 @@ module StandardId
12
12
  validates :name, presence: true, length: { maximum: 255 }
13
13
  validates :description, length: { maximum: 1000 }
14
14
  validates :redirect_uris, presence: true
15
+ validate :redirect_uris_must_be_absolute_without_query_or_fragment
15
16
  validates :client_type, inclusion: { in: %w[confidential public] }
16
17
  validates :grant_types, presence: true
17
18
  validates :response_types, presence: true
@@ -22,6 +23,11 @@ module StandardId
22
23
  validates :access_token_lifetime, :refresh_token_lifetime, :authorization_code_lifetime,
23
24
  presence: true, numericality: { greater_than: 0 }
24
25
 
26
+ # Security: public clients cannot opt out of PKCE. Public clients run in
27
+ # environments where a client secret cannot be kept confidential, so PKCE
28
+ # is the only protection against authorization code interception.
29
+ validate :public_clients_must_require_pkce
30
+
25
31
  # Scopes
26
32
  scope :active, -> { where(active: true) }
27
33
  scope :confidential, -> { where(client_type: "confidential") }
@@ -75,11 +81,50 @@ module StandardId
75
81
 
76
82
  def supports_pkce_method?(method)
77
83
  return false unless require_pkce?
78
- code_challenge_methods_array.include?(method.to_s)
84
+ normalized = method.to_s.downcase
85
+ code_challenge_methods_array.any? { |m| m.downcase == normalized }
86
+ end
87
+
88
+ # Validates a redirect_uri presented in an OAuth request against this
89
+ # client's registered URIs.
90
+ #
91
+ # OAuth 2.0 (RFC 6749 §3.1.2) requires the authorization server to compare
92
+ # the registered redirect URI and the request redirect URI using simple
93
+ # string comparison, with the exception that the authorization server may
94
+ # redirect with additional query parameters. We implement a stricter
95
+ # scheme+host+port+path match: the *request* URI may add query or fragment
96
+ # segments, but the scheme, host, port, and path must exactly match a
97
+ # registered URI. This prevents a class of "query-string piggyback" attacks
98
+ # where a registered callback at /cb is abused with a crafted query string
99
+ # (or, worse, a different path segment like /cb/evil).
100
+ #
101
+ # Subdomain wildcards are NOT supported — host must match exactly.
102
+ def valid_redirect_uri?(uri)
103
+ requested = self.class.parse_redirect_uri(uri)
104
+ return false unless requested
105
+
106
+ redirect_uris_array.any? do |registered_uri|
107
+ registered = self.class.parse_redirect_uri(registered_uri)
108
+ next false unless registered
109
+
110
+ registered.scheme == requested.scheme &&
111
+ registered.host == requested.host &&
112
+ registered.port == requested.port &&
113
+ registered.path == requested.path
114
+ end
79
115
  end
80
116
 
81
- def valid_redirect_uri?(uri)
82
- redirect_uris_array.include?(uri.to_s)
117
+ # Parse a redirect URI string into a URI object suitable for comparison.
118
+ # Returns nil for unparseable, relative, or scheme-less URIs.
119
+ def self.parse_redirect_uri(value)
120
+ return nil if value.to_s.strip.empty?
121
+
122
+ parsed = URI.parse(value.to_s.strip)
123
+ return nil if parsed.scheme.blank? || parsed.host.blank?
124
+
125
+ parsed
126
+ rescue URI::InvalidURIError
127
+ nil
83
128
  end
84
129
 
85
130
  def confidential?
@@ -127,6 +172,36 @@ module StandardId
127
172
 
128
173
  private
129
174
 
175
+ # Registered redirect URIs must be absolute (include scheme + host) and
176
+ # must NOT carry a query string or fragment. Allowing either would turn
177
+ # the whitelist into a prefix match and enable "query-param piggyback"
178
+ # attacks where a registered callback is reused with attacker-controlled
179
+ # parameters.
180
+ def redirect_uris_must_be_absolute_without_query_or_fragment
181
+ redirect_uris_array.each do |value|
182
+ parsed = self.class.parse_redirect_uri(value)
183
+ if parsed.nil?
184
+ errors.add(:redirect_uris, "contains an invalid URI (#{value.inspect}). Redirect URIs must be absolute (scheme + host)")
185
+ next
186
+ end
187
+
188
+ if parsed.query.present?
189
+ errors.add(:redirect_uris, "must not contain a query string (#{value.inspect}). Register the base URI only; OAuth adds query params at runtime")
190
+ end
191
+
192
+ if parsed.fragment.present?
193
+ errors.add(:redirect_uris, "must not contain a fragment (#{value.inspect})")
194
+ end
195
+ end
196
+ end
197
+
198
+ def public_clients_must_require_pkce
199
+ return unless client_type == "public"
200
+ return if require_pkce?
201
+
202
+ errors.add(:require_pkce, "public clients must have require_pkce enabled")
203
+ end
204
+
130
205
  def generate_client_id
131
206
  self.client_id ||= SecureRandom.hex(16)
132
207
  end
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
7
+ .container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
8
+ .button { display: inline-block; padding: 12px 24px; background-color: #111111; color: #ffffff !important; text-decoration: none; border-radius: 6px; font-weight: 600; }
9
+ .footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
10
+ .fallback { margin-top: 16px; font-size: 13px; color: #555555; word-break: break-all; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <div class="container">
15
+ <p>Hi,</p>
16
+ <p>We received a request to reset the password for the account associated with this email address. Click the button below to choose a new password:</p>
17
+ <p style="text-align: center; margin: 24px 0;">
18
+ <a class="button" href="<%= @reset_url %>">Reset your password</a>
19
+ </p>
20
+ <p class="fallback">If the button doesn't work, paste this link into your browser: <%= @reset_url %></p>
21
+ <p>If you did not request a password reset, you can safely ignore this email.</p>
22
+ <div class="footer">
23
+ <p>This is an automated message. Please do not reply.</p>
24
+ </div>
25
+ </div>
26
+ </body>
27
+ </html>
@@ -0,0 +1,12 @@
1
+ Hi,
2
+
3
+ We received a request to reset the password for the account associated with this email address.
4
+
5
+ Use the link below to choose a new password:
6
+
7
+ <%= @reset_url %>
8
+
9
+ If you did not request a password reset, you can safely ignore this email.
10
+
11
+ --
12
+ This is an automated message. Please do not reply.