standard_id 0.9.0 → 0.10.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/standard_id/inertia_rendering.rb +2 -1
  3. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +27 -0
  4. data/app/controllers/standard_id/api/base_controller.rb +2 -0
  5. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +6 -0
  6. data/app/controllers/standard_id/api/passwordless_controller.rb +15 -0
  7. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  8. data/app/controllers/standard_id/web/login_controller.rb +15 -0
  9. data/app/controllers/standard_id/web/login_verify_controller.rb +28 -4
  10. data/app/controllers/standard_id/web/verify_email/start_controller.rb +15 -0
  11. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +15 -0
  12. data/app/mailers/standard_id/passwordless_mailer.rb +16 -0
  13. data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +24 -0
  14. data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +12 -0
  15. data/lib/generators/standard_id/install/templates/standard_id.rb +5 -0
  16. data/lib/standard_id/config/schema.rb +40 -0
  17. data/lib/standard_id/engine.rb +1 -0
  18. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +37 -0
  19. data/lib/standard_id/jwt_service.rb +4 -3
  20. data/lib/standard_id/oauth/token_grant_flow.rb +17 -1
  21. data/lib/standard_id/passwordless/base_strategy.rb +34 -2
  22. data/lib/standard_id/passwordless/email_strategy.rb +7 -0
  23. data/lib/standard_id/passwordless/sms_strategy.rb +5 -0
  24. data/lib/standard_id/passwordless/verification_service.rb +56 -16
  25. data/lib/standard_id/passwordless.rb +56 -0
  26. data/lib/standard_id/rate_limit_store.rb +42 -0
  27. data/lib/standard_id/version.rb +1 -1
  28. data/lib/standard_id.rb +3 -0
  29. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c621a201633467c0738625eacb1f0c59c0b8ee5b670903cb09b1a1a4f6bfd01
4
- data.tar.gz: 2b4b97e69b5b8dba07f1df919fd3d3b5ef4dda4fa6b72ae54c317cfb96bb1320
3
+ metadata.gz: 7ad192cf3b1bad92d1ec8322e203804401d8c10e54ebfcde4340f7840fa624bd
4
+ data.tar.gz: 0eb0cc7c613bd3fdd981818c6d4fd359f809003da34d6e15f5e2fea9fd0eab74
5
5
  SHA512:
6
- metadata.gz: 22613e053bfa27512b39c38af4be699686ed8ef83b296efbbd17b131e60f12923fc6efdb8b572833849807c3928fb8cecbaee4d72e1e6da60e0b37b606555a3b
7
- data.tar.gz: 36e22ae1dca29a4350350c079eb7f1b4166c341f3d435bcdd666d7603b8f479de0dbee957b75fd807a42f7deeaf272d69badf6fe61d978151dcebb1f48c92401
6
+ metadata.gz: 478082fd5a80a66ded10834c7bd1db32912a222086f5fc8db521a90acceb1459418492581d89ca90d5a7d8fb438b3c74845372fe551d52eb892c297136acc002
7
+ data.tar.gz: bca63014a4ca013f152bb5bd9112f3649d0e7848d22b7d1114e9bd2715e2847369610e735b83f20bda3aad3fca45936d0f7452438f2a8144d24ddba7b3cb2c07
@@ -57,7 +57,8 @@ module StandardId
57
57
  password_reset: web.password_reset,
58
58
  email_verification: web.email_verification,
59
59
  phone_verification: web.phone_verification,
60
- sessions_management: web.sessions_management
60
+ sessions_management: web.sessions_management,
61
+ passwordless_registration: web.passwordless_registration
61
62
  }
62
63
  end
63
64
  end
@@ -0,0 +1,27 @@
1
+ module StandardId
2
+ module RateLimitHandling
3
+ extend ActiveSupport::Concern
4
+
5
+ RATE_LIMIT_STORE = StandardId::RateLimitStore.new
6
+
7
+ included do
8
+ rescue_from ActionController::TooManyRequests, with: :handle_rate_limited
9
+ end
10
+
11
+ private
12
+
13
+ def handle_rate_limited(_exception)
14
+ response.set_header("Retry-After", 15.minutes.to_i.to_s)
15
+
16
+ if self.class.ancestors.include?(ActionController::API)
17
+ render json: {
18
+ error: "rate_limit_exceeded",
19
+ error_description: "Too many requests. Please try again later."
20
+ }, status: :too_many_requests
21
+ else
22
+ flash[:alert] = "Too many requests. Please try again later."
23
+ redirect_to request.referer || main_app.root_path, status: :see_other
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,9 +1,11 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class BaseController < ActionController::API
4
+ include ActionController::RateLimiting
4
5
  include StandardId::ControllerPolicy
5
6
  include StandardId::ApiAuthentication
6
7
  include StandardId::SetCurrentRequestDetails
8
+ include StandardId::RateLimitHandling
7
9
 
8
10
  before_action -> { Current.scope = :api if defined?(::Current) }
9
11
  before_action :validate_content_type!
@@ -6,6 +6,12 @@ module StandardId
6
6
 
7
7
  skip_before_action :validate_content_type!
8
8
 
9
+ # RAR-51/RAR-60: Rate limit token requests by IP (30 per 15 minutes)
10
+ rate_limit to: StandardId.config.rate_limits.api_token_per_ip,
11
+ within: 15.minutes,
12
+ only: :create,
13
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
14
+
9
15
  FLOW_STRATEGIES = {
10
16
  "client_credentials" => StandardId::Oauth::ClientCredentialsFlow,
11
17
  "authorization_code" => StandardId::Oauth::AuthorizationCodeFlow,
@@ -5,6 +5,21 @@ module StandardId
5
5
 
6
6
  include StandardId::PasswordlessStrategy
7
7
 
8
+ # RAR-60: Rate limit OTP initiation by IP (10 per hour)
9
+ rate_limit to: StandardId.config.rate_limits.api_passwordless_start_per_ip,
10
+ within: 1.hour,
11
+ name: "passwordless-ip",
12
+ only: :start,
13
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
14
+
15
+ # RAR-60: Rate limit OTP initiation by target (5 per 15 minutes)
16
+ rate_limit to: StandardId.config.rate_limits.api_passwordless_start_per_target,
17
+ within: 15.minutes,
18
+ by: -> { "api-passwordless:#{(params[:username] || params[:email] || params[:phone_number]).to_s.strip.downcase}" },
19
+ name: "passwordless-target",
20
+ only: :start,
21
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
22
+
8
23
  def start
9
24
  raise StandardId::InvalidRequestError, "username, email, or phone_number parameter is required" if start_params[:username].blank?
10
25
 
@@ -5,6 +5,7 @@ module StandardId
5
5
  include StandardId::WebAuthentication
6
6
  include StandardId::SetCurrentRequestDetails
7
7
  include StandardId::WebMechanismGate
8
+ include StandardId::RateLimitHandling
8
9
 
9
10
  include StandardId::WebEngine.routes.url_helpers
10
11
  helper StandardId::WebEngine.routes.url_helpers
@@ -10,6 +10,21 @@ module StandardId
10
10
 
11
11
  layout "public"
12
12
 
13
+ # RAR-51: Rate limit login attempts by IP (20 per 15 minutes)
14
+ rate_limit to: StandardId.config.rate_limits.password_login_per_ip,
15
+ within: 15.minutes,
16
+ name: "login-ip",
17
+ only: :create,
18
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
19
+
20
+ # RAR-51: Rate limit login attempts by email target (5 per 15 minutes)
21
+ rate_limit to: StandardId.config.rate_limits.password_login_per_email,
22
+ within: 15.minutes,
23
+ by: -> { "login-email:#{params.dig(:login, :email).to_s.strip.downcase}" },
24
+ name: "login-email",
25
+ only: :create,
26
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
27
+
13
28
  skip_before_action :require_browser_session!, only: [:show, :create]
14
29
 
15
30
  before_action :redirect_if_authenticated, only: [:show]
@@ -9,6 +9,13 @@ module StandardId
9
9
 
10
10
  layout "public"
11
11
 
12
+ # RAR-60: Rate limit OTP verification attempts by IP (20 per 15 minutes)
13
+ rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
14
+ within: 15.minutes,
15
+ name: "otp-verify-ip",
16
+ only: :update,
17
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
18
+
12
19
  skip_before_action :require_browser_session!, only: [:show, :update]
13
20
  before_action :redirect_if_authenticated, only: [:show]
14
21
  before_action :require_otp_payload!
@@ -26,11 +33,12 @@ module StandardId
26
33
  return
27
34
  end
28
35
 
29
- result = StandardId::Passwordless::VerificationService.verify(
30
- connection: @otp_data[:connection],
36
+ result = StandardId::Passwordless.verify(
31
37
  username: @otp_data[:username],
32
38
  code: code,
33
- request: request
39
+ connection: @otp_data[:connection],
40
+ request: request,
41
+ allow_registration: passwordless_registration_enabled?
34
42
  )
35
43
 
36
44
  unless result.success?
@@ -45,7 +53,10 @@ module StandardId
45
53
  session_manager.sign_in_account(account)
46
54
  emit_authentication_succeeded(account)
47
55
 
48
- invoke_after_account_created(account, { mechanism: "passwordless", provider: nil }) if newly_created
56
+ if newly_created
57
+ emit_passwordless_account_created(account)
58
+ invoke_after_account_created(account, { mechanism: "passwordless", provider: nil })
59
+ end
49
60
 
50
61
  context = { connection: @otp_data[:connection], provider: nil }
51
62
  redirect_override = invoke_after_sign_in(account, context)
@@ -81,6 +92,19 @@ module StandardId
81
92
  end
82
93
  end
83
94
 
95
+ def passwordless_registration_enabled?
96
+ StandardId.config.web.passwordless_registration
97
+ end
98
+
99
+ def emit_passwordless_account_created(account)
100
+ StandardId::Events.publish(
101
+ StandardId::Events::PASSWORDLESS_ACCOUNT_CREATED,
102
+ account: account,
103
+ channel: @otp_data[:connection],
104
+ identifier: @otp_data[:username]
105
+ )
106
+ end
107
+
84
108
  def emit_authentication_succeeded(account)
85
109
  StandardId::Events.publish(
86
110
  StandardId::Events::AUTHENTICATION_SUCCEEDED,
@@ -2,6 +2,21 @@ module StandardId
2
2
  module Web
3
3
  module VerifyEmail
4
4
  class StartController < BaseController
5
+ # RAR-56: Rate limit verification code generation by IP (10 per hour)
6
+ rate_limit to: StandardId.config.rate_limits.verification_start_per_ip,
7
+ within: 1.hour,
8
+ name: "verify-email-ip",
9
+ only: :create,
10
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
11
+
12
+ # RAR-56: Rate limit verification code generation by email target (3 per 15 minutes)
13
+ rate_limit to: StandardId.config.rate_limits.verification_start_per_target,
14
+ within: 15.minutes,
15
+ by: -> { "verify-email:#{params[:email].to_s.strip.downcase}" },
16
+ name: "verify-email-target",
17
+ only: :create,
18
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
19
+
5
20
  def show
6
21
  render plain: "verify email start", status: :ok
7
22
  end
@@ -2,6 +2,21 @@ module StandardId
2
2
  module Web
3
3
  module VerifyPhone
4
4
  class StartController < BaseController
5
+ # RAR-56: Rate limit verification code generation by IP (10 per hour)
6
+ rate_limit to: StandardId.config.rate_limits.verification_start_per_ip,
7
+ within: 1.hour,
8
+ name: "verify-phone-ip",
9
+ only: :create,
10
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
11
+
12
+ # RAR-56: Rate limit verification code generation by phone target (3 per 15 minutes)
13
+ rate_limit to: StandardId.config.rate_limits.verification_start_per_target,
14
+ within: 15.minutes,
15
+ by: -> { "verify-phone:#{params[:phone_number].to_s.strip}" },
16
+ name: "verify-phone-target",
17
+ only: :create,
18
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
19
+
5
20
  def show
6
21
  render plain: "verify phone start", status: :ok
7
22
  end
@@ -0,0 +1,16 @@
1
+ module StandardId
2
+ class PasswordlessMailer < ApplicationMailer
3
+ layout false
4
+
5
+ def otp_email
6
+ @otp_code = params[:otp_code]
7
+ @email = params[:email]
8
+
9
+ mail(
10
+ to: @email,
11
+ from: StandardId.config.passwordless.mailer_from,
12
+ subject: StandardId.config.passwordless.mailer_subject
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
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
+ .code { font-size: 32px; font-weight: bold; letter-spacing: 8px; text-align: center; padding: 20px; background-color: #f0f0f0; border-radius: 6px; margin: 24px 0; font-family: monospace; }
9
+ .footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div class="container">
14
+ <p>Hi,</p>
15
+ <p>Use the following code to sign in:</p>
16
+ <div class="code"><%= @otp_code %></div>
17
+ <p>This code will expire in <%= StandardId.config.passwordless.code_ttl / 60 %> minutes.</p>
18
+ <p>If you did not request this code, you can safely ignore this email.</p>
19
+ <div class="footer">
20
+ <p>This is an automated message. Please do not reply.</p>
21
+ </div>
22
+ </div>
23
+ </body>
24
+ </html>
@@ -0,0 +1,12 @@
1
+ Hi,
2
+
3
+ Use the following code to sign in:
4
+
5
+ <%= @otp_code %>
6
+
7
+ This code will expire in <%= StandardId.config.passwordless.code_ttl / 60 %> minutes.
8
+
9
+ If you did not request this code, you can safely ignore this email.
10
+
11
+ --
12
+ This is an automated message. Please do not reply.
@@ -54,6 +54,11 @@ StandardId.configure do |c|
54
54
  # }
55
55
  # }
56
56
  # c.oauth.allowed_audiences = %w[web mobile admin] # Empty = no validation
57
+ #
58
+ # Custom claims added to every access token (independent of scopes).
59
+ # Receives keyword arguments: account:, client:, request:, audience:
60
+ # Must return a Hash. Reserved JWT keys (sub, exp, iat, etc.) are excluded.
61
+ # c.oauth.custom_claims = ->(account:, **) { { channel_id: account.channel_id } }
57
62
 
58
63
  # JWT Signing Configuration (Asymmetric Algorithms)
59
64
  # By default, JWTs are signed with HS256 using Rails.application.secret_key_base.
@@ -50,6 +50,20 @@ StandardConfig.schema.draw do
50
50
  field :max_attempts, type: :integer, default: 3
51
51
  field :retry_delay, type: :integer, default: 30 # 30 seconds
52
52
  field :bypass_code, type: :string, default: nil # E2E testing only — NEVER set in production
53
+
54
+ # Custom account factory for passwordless registration.
55
+ # When set, replaces the default find_or_create_account! logic in strategies.
56
+ # Must be a callable (lambda/proc) that receives (identifier:, params:, request:)
57
+ # and returns an Account (or account-like) record.
58
+ # When nil (default), uses the built-in strategy behavior.
59
+ field :account_factory, type: :any, default: nil
60
+
61
+ # OTP email delivery mode:
62
+ # :custom — (default) host app handles delivery via event subscriber
63
+ # :built_in — engine sends OTP emails automatically using PasswordlessMailer
64
+ field :delivery, type: :symbol, default: :custom
65
+ field :mailer_from, type: :string, default: "noreply@example.com"
66
+ field :mailer_subject, type: :string, default: "Your sign-in code"
53
67
  end
54
68
 
55
69
  scope :password do
@@ -90,6 +104,12 @@ StandardConfig.schema.draw do
90
104
  # Asymmetric (RSA): :rs256, :rs384, :rs512
91
105
  # Asymmetric (ECDSA): :es256, :es384, :es512
92
106
  field :signing_algorithm, type: :symbol, default: :hs256
107
+
108
+ # Custom claims callable for encoding additional claims into JWT access tokens.
109
+ # Receives keyword arguments: account:, client:, request:, audience:
110
+ # Must return a Hash of custom claims to merge into the JWT payload.
111
+ # Example: ->(account:, **) { { channel_id: account.channel_id } }
112
+ field :custom_claims, type: :any, default: nil
93
113
  end
94
114
 
95
115
  scope :social do
@@ -108,5 +128,25 @@ StandardConfig.schema.draw do
108
128
  field :email_verification, type: :boolean, default: true
109
129
  field :phone_verification, type: :boolean, default: true
110
130
  field :sessions_management, type: :boolean, default: true
131
+ field :passwordless_registration, type: :boolean, default: false
132
+ end
133
+
134
+ # Rate limiting defaults (used by Rails 8 built-in rate_limit DSL)
135
+ scope :rate_limits do
136
+ # RAR-51: Password login
137
+ field :password_login_per_ip, type: :integer, default: 20 # per 15 minutes
138
+ field :password_login_per_email, type: :integer, default: 5 # per 15 minutes
139
+
140
+ # RAR-60: OTP verification
141
+ field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
142
+
143
+ # RAR-56: Email/phone verification code generation
144
+ field :verification_start_per_target, type: :integer, default: 3 # per 15 minutes
145
+ field :verification_start_per_ip, type: :integer, default: 10 # per hour
146
+
147
+ # API equivalents
148
+ field :api_passwordless_start_per_ip, type: :integer, default: 10 # per hour
149
+ field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
150
+ field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
111
151
  end
112
152
  end
@@ -23,6 +23,7 @@ module StandardId
23
23
 
24
24
  StandardId::Events::Subscribers::AccountStatusSubscriber.attach
25
25
  StandardId::Events::Subscribers::AccountLockingSubscriber.attach
26
+ StandardId::Events::Subscribers::PasswordlessDeliverySubscriber.attach
26
27
 
27
28
  if StandardId.config.issuer.blank?
28
29
  Rails.logger.warn("[StandardId] No issuer configured. JWT tokens will not include or verify the 'iss' claim. " \
@@ -0,0 +1,37 @@
1
+ module StandardId
2
+ module Events
3
+ module Subscribers
4
+ class PasswordlessDeliverySubscriber < Base
5
+ subscribe_to StandardId::Events::PASSWORDLESS_CODE_GENERATED
6
+
7
+ def call(event)
8
+ return unless built_in_delivery?
9
+ return unless event[:channel] == "email"
10
+
11
+ identifier = event[:identifier]
12
+ code = event[:code_challenge]&.code
13
+
14
+ return if identifier.blank? || code.blank?
15
+
16
+ StandardId::PasswordlessMailer.with(
17
+ email: identifier,
18
+ otp_code: code
19
+ ).otp_email.deliver_later
20
+ end
21
+
22
+ def handle_error(error, event)
23
+ StandardId.logger.error(
24
+ "[StandardId::PasswordlessDelivery] Failed to deliver OTP email " \
25
+ "for #{event[:identifier]}: #{error.message}"
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def built_in_delivery?
32
+ StandardId.config.passwordless.delivery == :built_in
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -7,7 +7,7 @@ require "digest"
7
7
  module StandardId
8
8
  class JwtService
9
9
  RESERVED_JWT_KEYS = %i[sub client_id scope grant_type exp iat aud iss nbf jti]
10
- BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type aud]
10
+ BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type aud claims]
11
11
 
12
12
  # Supported signing algorithms categorized by type
13
13
  # Symmetric: use shared secret (Rails.application.secret_key_base)
@@ -187,7 +187,8 @@ module StandardId
187
187
  client_id: payload[:client_id],
188
188
  scopes: scopes,
189
189
  grant_type: payload[:grant_type],
190
- aud: payload[:aud]
190
+ aud: payload[:aud],
191
+ claims: payload.to_h
191
192
  )
192
193
  end
193
194
 
@@ -239,7 +240,7 @@ module StandardId
239
240
  def self.claim_resolver_keys
240
241
  resolvers = StandardId.config.oauth.claim_resolvers
241
242
  keys = Hash.try_convert(resolvers)&.keys
242
- keys.compact.map(&:to_sym).uniq.excluding(*RESERVED_JWT_KEYS)
243
+ keys.compact.map(&:to_sym).uniq.excluding(*RESERVED_JWT_KEYS, *BASE_SESSION_FIELDS)
243
244
  rescue StandardError
244
245
  []
245
246
  end
@@ -62,7 +62,7 @@ module StandardId
62
62
  aud: audience
63
63
  }.compact
64
64
 
65
- base_payload.merge(claims_from_scope_mapping)
65
+ base_payload.merge(claims_from_scope_mapping).merge(claims_from_custom_claims)
66
66
  end
67
67
 
68
68
  def token_expiry
@@ -74,6 +74,7 @@ module StandardId
74
74
  end
75
75
 
76
76
  def generate_refresh_token
77
+ # custom_claims not included — refresh tokens carry identity only
77
78
  jti = SecureRandom.uuid
78
79
  payload = {
79
80
  sub: subject_id,
@@ -155,6 +156,21 @@ module StandardId
155
156
  end
156
157
  end
157
158
 
159
+ def claims_from_custom_claims
160
+ callable = StandardId.config.oauth.custom_claims
161
+ return {} unless callable.respond_to?(:call)
162
+
163
+ result = StandardId::Utils::CallableParameterFilter.filter(callable, claim_resolvers_context)
164
+ claims = callable.call(**result)
165
+ return {} unless claims.is_a?(Hash)
166
+
167
+ # Prevent custom claims from overriding reserved JWT keys or base session fields
168
+ claims.symbolize_keys.except(*StandardId::JwtService::RESERVED_JWT_KEYS, *StandardId::JwtService::BASE_SESSION_FIELDS)
169
+ rescue StandardError => e
170
+ StandardId.config.logger&.error("[StandardId] custom_claims callable raised: #{e.message}")
171
+ {}
172
+ end
173
+
158
174
  def claims_from_scope_mapping
159
175
  scope_claims = StandardId.config.oauth.scope_claims.with_indifferent_access
160
176
  resolvers = StandardId.config.oauth.claim_resolvers.with_indifferent_access
@@ -53,12 +53,37 @@ module StandardId
53
53
  raise NotImplementedError
54
54
  end
55
55
 
56
+ def find_existing_account(_username)
57
+ raise NotImplementedError
58
+ end
59
+
56
60
  public
57
61
 
58
- # Public wrapper to reuse account lookup/creation outside OTP verification
62
+ # Public wrapper to reuse account lookup/creation outside OTP verification.
63
+ # When a custom account_factory is configured, delegates to it instead of
64
+ # the built-in find_or_create_account! logic.
59
65
  def find_or_create_account(username)
60
66
  validate_username!(username)
61
- find_or_create_account!(username)
67
+
68
+ factory = StandardId.config.passwordless.account_factory
69
+ if factory.respond_to?(:call)
70
+ account = factory.call(
71
+ identifier: username,
72
+ params: request_params,
73
+ request: request
74
+ )
75
+ raise StandardId::InvalidRequestError, "account_factory must return an account" unless account.present?
76
+ account
77
+ else
78
+ find_or_create_account!(username)
79
+ end
80
+ end
81
+
82
+ # Public wrapper to look up an existing account without creating one.
83
+ # Returns nil if no account is found for the given username.
84
+ def find_account(username)
85
+ validate_username!(username)
86
+ find_existing_account(username)
62
87
  end
63
88
 
64
89
  def identifier_class
@@ -72,6 +97,13 @@ module StandardId
72
97
 
73
98
  private
74
99
 
100
+ # Extract request parameters safely. Returns an empty hash if the request
101
+ # does not support parameters (e.g. test doubles).
102
+ def request_params
103
+ return {} unless request.respond_to?(:params)
104
+ request.params
105
+ end
106
+
75
107
  def emit_code_requested(username)
76
108
  StandardId::Events.publish(
77
109
  StandardId::Events::PASSWORDLESS_CODE_REQUESTED,
@@ -15,7 +15,14 @@ module StandardId
15
15
  Account.find_or_create_by_verified_email!(email)
16
16
  end
17
17
 
18
+ def find_existing_account(email)
19
+ normalized = email.to_s.strip.downcase
20
+ identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: normalized)
21
+ identifier&.account
22
+ end
23
+
18
24
  def sender_callback
25
+ return nil if StandardId.config.passwordless.delivery == :built_in
19
26
  StandardId.config.passwordless_email_sender
20
27
  end
21
28
  end
@@ -21,6 +21,11 @@ module StandardId
21
21
  Account.create!(identifiers_attributes:)
22
22
  end
23
23
 
24
+ def find_existing_account(phone_number)
25
+ identifier = StandardId::PhoneNumberIdentifier.includes(:account).find_by(value: phone_number)
26
+ identifier&.account
27
+ end
28
+
24
29
  def sender_callback
25
30
  StandardId.config.passwordless_sms_sender
26
31
  end
@@ -5,10 +5,13 @@ module StandardId
5
5
  # - success?: true/false
6
6
  # - account: the resolved account (nil on failure)
7
7
  # - challenge: the consumed CodeChallenge (nil on failure)
8
- # - error: error message string (nil on success)
8
+ # - error: human-readable error message string (nil on success)
9
+ # - error_code: machine-readable symbol (nil on success)
10
+ # One of :invalid_code, :expired, :max_attempts, :not_found, :blank_code,
11
+ # :account_not_found, :server_error
9
12
  # - attempts: nil on success, 0 when no challenge was found (fabricated
10
13
  # target), or 1+ for wrong-code failures against an active challenge
11
- Result = Data.define(:success?, :account, :challenge, :error, :attempts)
14
+ Result = Data.define(:success?, :account, :challenge, :error, :error_code, :attempts)
12
15
 
13
16
  STRATEGY_MAP = {
14
17
  "email" => StandardId::Passwordless::EmailStrategy,
@@ -54,7 +57,7 @@ module StandardId
54
57
  # render_error(result.error)
55
58
  # end
56
59
  #
57
- def verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil)
60
+ def verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil, allow_registration: true)
58
61
  # Allow callers to use connection:/username: instead of email:/phone:
59
62
  if connection.present?
60
63
  if username.blank?
@@ -68,31 +71,42 @@ module StandardId
68
71
  end
69
72
  end
70
73
 
71
- new(email: email, phone: phone, code: code, request: request).verify
74
+ new(email: email, phone: phone, code: code, request: request, allow_registration: allow_registration).verify
72
75
  end
73
76
  end
74
77
 
75
- def initialize(email: nil, phone: nil, code:, request:)
78
+ def initialize(email: nil, phone: nil, code:, request:, allow_registration: true)
76
79
  @code = code.to_s.strip
77
80
  @request = request
81
+ @allow_registration = allow_registration
78
82
  resolve_target_and_channel!(email, phone)
79
83
  end
80
84
 
81
85
  def verify
82
86
  if @code.blank?
83
- return failure("Code is required")
87
+ return failure("Code is required", error_code: :blank_code)
84
88
  end
85
89
 
86
90
  bypass_result = try_bypass
87
91
  return bypass_result if bypass_result
88
92
 
89
93
  challenge = find_active_challenge
90
- code_matches = challenge.present? && secure_compare(challenge.code, @code)
94
+
95
+ unless challenge.present?
96
+ return failure("Invalid or expired verification code", error_code: :not_found, attempts: 0)
97
+ end
98
+
99
+ code_matches = secure_compare(challenge.code, @code)
91
100
  attempts = record_failed_attempt(challenge, code_matches)
92
101
 
93
102
  unless code_matches
94
- emit_otp_validation_failed(attempts) if challenge.present?
95
- return failure("Invalid or expired verification code", attempts: attempts)
103
+ emit_otp_validation_failed(attempts)
104
+
105
+ if attempts >= StandardId.config.passwordless.max_attempts
106
+ return failure("Too many failed attempts. Please request a new code.", error_code: :max_attempts, attempts: attempts)
107
+ end
108
+
109
+ return failure("Invalid or expired verification code", error_code: :invalid_code, attempts: attempts)
96
110
  end
97
111
 
98
112
  # Re-fetch with lock inside a transaction to prevent concurrent use.
@@ -103,12 +117,18 @@ module StandardId
103
117
  # No OTP_VALIDATION_FAILED event here: the code was correct but the
104
118
  # challenge was consumed by a concurrent request — not an attacker
105
119
  # guessing codes. Emitting a failure event would be misleading.
106
- result = failure("Invalid or expired verification code", attempts: attempts)
120
+ result = failure("Invalid or expired verification code", error_code: :expired, attempts: attempts)
107
121
  raise ActiveRecord::Rollback
108
122
  end
109
123
 
110
124
  strategy = strategy_for(@channel)
111
- account = strategy.find_or_create_account(@target)
125
+ account = resolve_account(strategy)
126
+
127
+ unless account
128
+ label = @channel == "sms" ? "phone number" : "email address"
129
+ result = failure("No account found for this #{label}", error_code: :account_not_found)
130
+ raise ActiveRecord::Rollback
131
+ end
112
132
 
113
133
  locked_challenge.use!
114
134
 
@@ -123,9 +143,9 @@ module StandardId
123
143
 
124
144
  result
125
145
  rescue ActiveRecord::RecordNotFound
126
- failure("Invalid or expired verification code")
146
+ failure("Invalid or expired verification code", error_code: :expired)
127
147
  rescue ActiveRecord::RecordInvalid => e
128
- failure("Unable to complete verification: #{e.record.errors.full_messages.to_sentence}")
148
+ failure("Unable to complete verification: #{e.record.errors.full_messages.to_sentence}", error_code: :server_error)
129
149
  end
130
150
 
131
151
  private
@@ -147,7 +167,15 @@ module StandardId
147
167
  return unless secure_compare(bypass_code, @code)
148
168
 
149
169
  strategy = strategy_for(@channel)
150
- account = strategy.find_or_create_account(@target)
170
+ account = nil
171
+ ActiveRecord::Base.transaction do
172
+ account = resolve_account(strategy)
173
+ end
174
+
175
+ unless account
176
+ label = @channel == "sms" ? "phone number" : "email address"
177
+ return failure("No account found for this #{label}", error_code: :account_not_found)
178
+ end
151
179
 
152
180
  StandardId::Events.publish(
153
181
  StandardId::Events::OTP_VALIDATED,
@@ -183,7 +211,6 @@ module StandardId
183
211
  # rescued alongside account-creation errors. This is intentional — both
184
212
  # represent unexpected persistence failures and warrant the same response.
185
213
  def record_failed_attempt(challenge, code_matches)
186
- return 0 if challenge.blank?
187
214
  return 0 if code_matches
188
215
 
189
216
  attempts = (challenge.metadata["attempts"] || 0) + 1
@@ -199,6 +226,17 @@ module StandardId
199
226
  ActiveSupport::SecurityUtils.secure_compare(a.to_s, b.to_s)
200
227
  end
201
228
 
229
+ # Resolve the account for the target identifier.
230
+ # When @allow_registration is true, creates a new account if none exists.
231
+ # When false, returns nil if no account is found.
232
+ def resolve_account(strategy)
233
+ if @allow_registration
234
+ strategy.find_or_create_account(@target)
235
+ else
236
+ strategy.find_account(@target)
237
+ end
238
+ end
239
+
202
240
  def strategy_for(channel)
203
241
  klass = STRATEGY_MAP[channel]
204
242
  raise StandardId::InvalidRequestError, "Unsupported connection type: #{channel}" unless klass
@@ -240,17 +278,19 @@ module StandardId
240
278
  account: account,
241
279
  challenge: challenge,
242
280
  error: nil,
281
+ error_code: nil,
243
282
  attempts: nil
244
283
  )
245
284
  end
246
285
 
247
286
  # attempts is nil on success (not meaningful) and 0 when no challenge was found.
248
- def failure(error, attempts: nil)
287
+ def failure(error, error_code: nil, attempts: nil)
249
288
  Result.new(
250
289
  "success?": false,
251
290
  account: nil,
252
291
  challenge: nil,
253
292
  error: error,
293
+ error_code: error_code,
254
294
  attempts: attempts
255
295
  )
256
296
  end
@@ -0,0 +1,56 @@
1
+ require "standard_id/passwordless/verification_service"
2
+
3
+ module StandardId
4
+ module Passwordless
5
+ class << self
6
+ # Public API for verifying a passwordless OTP code.
7
+ #
8
+ # This is the recommended entry point for host apps that need OTP
9
+ # verification without mounting WebEngine. It wraps
10
+ # VerificationService.verify with the same interface and result type.
11
+ #
12
+ # @param username [String] The identifier value (email or phone number)
13
+ # @param code [String] The OTP code to verify
14
+ # @param connection [String] Channel type ("email" or "sms")
15
+ # @param request [ActionDispatch::Request] The current request
16
+ # @return [VerificationService::Result] A result with:
17
+ # - success? — true when verification succeeded
18
+ # - account — the authenticated/created account (nil on failure)
19
+ # - challenge — the consumed CodeChallenge (nil on failure)
20
+ # - error — human-readable message (nil on success)
21
+ # - error_code — machine-readable symbol (nil on success):
22
+ # :invalid_code, :expired, :max_attempts, :not_found, :blank_code,
23
+ # :account_not_found, :server_error
24
+ # - attempts — failed attempt count (nil on success)
25
+ #
26
+ # @example
27
+ # result = StandardId::Passwordless.verify(
28
+ # username: "user@example.com",
29
+ # code: "123456",
30
+ # connection: "email",
31
+ # request: request
32
+ # )
33
+ #
34
+ # if result.success?
35
+ # sign_in(result.account)
36
+ # else
37
+ # case result.error_code
38
+ # when :invalid_code then render_invalid_code
39
+ # when :expired then render_expired
40
+ # when :max_attempts then render_locked_out
41
+ # when :not_found then render_not_found
42
+ # end
43
+ # end
44
+ #
45
+ def verify(username:, code:, connection:, request:, allow_registration: true)
46
+ VerificationService.verify(
47
+ connection: connection,
48
+ username: username,
49
+ code: code,
50
+ request: request,
51
+ allow_registration: allow_registration
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,42 @@
1
+ module StandardId
2
+ # A delegating cache store for rate limiting that lazily resolves the
3
+ # backing store at request time. This allows the engine's rate_limit
4
+ # declarations to work regardless of boot order, and respects the host
5
+ # app's cache configuration.
6
+ #
7
+ # Resolution order:
8
+ # 1. StandardId.config.cache_store (if it responds to :increment)
9
+ # 2. Rails.cache
10
+ class RateLimitStore
11
+ def increment(name, amount = 1, **options)
12
+ resolve_store.increment(name, amount, **options)
13
+ end
14
+
15
+ def read(name, **options)
16
+ resolve_store.read(name, **options)
17
+ end
18
+
19
+ def write(name, value, **options)
20
+ resolve_store.write(name, value, **options)
21
+ end
22
+
23
+ def delete(name, **options)
24
+ resolve_store.delete(name, **options)
25
+ end
26
+
27
+ def clear(**options)
28
+ resolve_store.clear(**options)
29
+ end
30
+
31
+ private
32
+
33
+ def resolve_store
34
+ configured = StandardId.config.cache_store
35
+ if configured.respond_to?(:increment)
36
+ configured
37
+ else
38
+ Rails.cache
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.9.0"
2
+ VERSION = "0.10.0"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -10,6 +10,7 @@ require "standard_id/events/subscribers/base"
10
10
  require "standard_id/events/subscribers/logging_subscriber"
11
11
  require "standard_id/events/subscribers/account_status_subscriber"
12
12
  require "standard_id/events/subscribers/account_locking_subscriber"
13
+ require "standard_id/events/subscribers/passwordless_delivery_subscriber"
13
14
  require "standard_id/account_status"
14
15
  require "standard_id/account_locking"
15
16
  require "standard_id/http_client"
@@ -40,9 +41,11 @@ require "standard_id/passwordless/base_strategy"
40
41
  require "standard_id/passwordless/email_strategy"
41
42
  require "standard_id/passwordless/sms_strategy"
42
43
  require "standard_id/passwordless/verification_service"
44
+ require "standard_id/passwordless"
43
45
  require "standard_id/authorization_bypass"
44
46
  require "standard_id/utils/callable_parameter_filter"
45
47
  require "standard_id/utils/ip_normalizer"
48
+ require "standard_id/rate_limit_store"
46
49
 
47
50
  require "concurrent/delay"
48
51
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -114,6 +114,7 @@ files:
114
114
  - app/controllers/concerns/standard_id/inertia_support.rb
115
115
  - app/controllers/concerns/standard_id/lifecycle_hooks.rb
116
116
  - app/controllers/concerns/standard_id/passwordless_strategy.rb
117
+ - app/controllers/concerns/standard_id/rate_limit_handling.rb
117
118
  - app/controllers/concerns/standard_id/sentry_context.rb
118
119
  - app/controllers/concerns/standard_id/set_current_request_details.rb
119
120
  - app/controllers/concerns/standard_id/social_authentication.rb
@@ -156,6 +157,7 @@ files:
156
157
  - app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb
157
158
  - app/jobs/standard_id/cleanup_expired_sessions_job.rb
158
159
  - app/mailers/standard_id/application_mailer.rb
160
+ - app/mailers/standard_id/passwordless_mailer.rb
159
161
  - app/models/concerns/standard_id/account_associations.rb
160
162
  - app/models/concerns/standard_id/credentiable.rb
161
163
  - app/models/concerns/standard_id/password_strength.rb
@@ -175,6 +177,8 @@ files:
175
177
  - app/models/standard_id/service_session.rb
176
178
  - app/models/standard_id/session.rb
177
179
  - app/models/standard_id/username_identifier.rb
180
+ - app/views/standard_id/passwordless_mailer/otp_email.html.erb
181
+ - app/views/standard_id/passwordless_mailer/otp_email.text.erb
178
182
  - app/views/standard_id/web/account/edit.html.erb
179
183
  - app/views/standard_id/web/account/show.html.erb
180
184
  - app/views/standard_id/web/auth/callback/providers/mobile_callback.html.erb
@@ -227,6 +231,7 @@ files:
227
231
  - lib/standard_id/events/subscribers/account_status_subscriber.rb
228
232
  - lib/standard_id/events/subscribers/base.rb
229
233
  - lib/standard_id/events/subscribers/logging_subscriber.rb
234
+ - lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb
230
235
  - lib/standard_id/http_client.rb
231
236
  - lib/standard_id/jwt_service.rb
232
237
  - lib/standard_id/oauth/authorization_code_authorization_flow.rb
@@ -244,12 +249,14 @@ files:
244
249
  - lib/standard_id/oauth/subflows/traditional_code_grant.rb
245
250
  - lib/standard_id/oauth/token_grant_flow.rb
246
251
  - lib/standard_id/oauth/token_lifetime_resolver.rb
252
+ - lib/standard_id/passwordless.rb
247
253
  - lib/standard_id/passwordless/base_strategy.rb
248
254
  - lib/standard_id/passwordless/email_strategy.rb
249
255
  - lib/standard_id/passwordless/sms_strategy.rb
250
256
  - lib/standard_id/passwordless/verification_service.rb
251
257
  - lib/standard_id/provider_registry.rb
252
258
  - lib/standard_id/providers/base.rb
259
+ - lib/standard_id/rate_limit_store.rb
253
260
  - lib/standard_id/testing.rb
254
261
  - lib/standard_id/testing/authentication_helpers.rb
255
262
  - lib/standard_id/testing/factories/credentials.rb