standard_id 0.9.0 → 0.11.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 (34) 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/lifecycle_hooks.rb +35 -6
  4. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +27 -0
  5. data/app/controllers/concerns/standard_id/web_authentication.rb +5 -1
  6. data/app/controllers/standard_id/api/base_controller.rb +2 -0
  7. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +6 -0
  8. data/app/controllers/standard_id/api/passwordless_controller.rb +15 -0
  9. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +3 -1
  10. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  11. data/app/controllers/standard_id/web/login_controller.rb +21 -2
  12. data/app/controllers/standard_id/web/login_verify_controller.rb +31 -5
  13. data/app/controllers/standard_id/web/signup_controller.rb +2 -1
  14. data/app/controllers/standard_id/web/verify_email/start_controller.rb +15 -0
  15. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +15 -0
  16. data/app/mailers/standard_id/passwordless_mailer.rb +16 -0
  17. data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +24 -0
  18. data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +12 -0
  19. data/config/brakeman.ignore +2 -2
  20. data/lib/generators/standard_id/install/templates/standard_id.rb +5 -0
  21. data/lib/standard_id/config/schema.rb +55 -6
  22. data/lib/standard_id/engine.rb +1 -0
  23. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +37 -0
  24. data/lib/standard_id/jwt_service.rb +4 -3
  25. data/lib/standard_id/oauth/token_grant_flow.rb +17 -1
  26. data/lib/standard_id/passwordless/base_strategy.rb +34 -2
  27. data/lib/standard_id/passwordless/email_strategy.rb +7 -0
  28. data/lib/standard_id/passwordless/sms_strategy.rb +5 -0
  29. data/lib/standard_id/passwordless/verification_service.rb +56 -16
  30. data/lib/standard_id/passwordless.rb +56 -0
  31. data/lib/standard_id/rate_limit_store.rb +42 -0
  32. data/lib/standard_id/version.rb +1 -1
  33. data/lib/standard_id.rb +3 -0
  34. 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: ebaef83f7c6b587f981d1eddf7b56638760d59e667b1c23f5cbb75d26e98db34
4
+ data.tar.gz: d1476d41140b17b43c745b92d63b64c1e2651be30c9e6714cf22971a04f99e26
5
5
  SHA512:
6
- metadata.gz: 22613e053bfa27512b39c38af4be699686ed8ef83b296efbbd17b131e60f12923fc6efdb8b572833849807c3928fb8cecbaee4d72e1e6da60e0b37b606555a3b
7
- data.tar.gz: 36e22ae1dca29a4350350c079eb7f1b4166c341f3d435bcdd666d7603b8f479de0dbee957b75fd807a42f7deeaf272d69badf6fe61d978151dcebb1f48c92401
6
+ metadata.gz: a163de182358974e8e5168194dc9af3e5f084a0de5c5dde0b663048918dbd22bbf326b0708fb5cc5c0ba0c5d92953baaf7b8ea4fd319225170164e762e663091
7
+ data.tar.gz: '068d45f4edce5e084b3f35e038dc0c4dbb377efa16dcb6dc4c29c1f0a15f0a5c319f110684d6ecb820cbb11a3e5fa4ef61198e4c5385e15513b9fc1f69d059bd'
@@ -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
@@ -4,20 +4,46 @@ module StandardId
4
4
 
5
5
  private
6
6
 
7
+ # Invoke the before_sign_in hook if configured.
8
+ # Called after credential verification, BEFORE session creation.
9
+ #
10
+ # @param account [Object] the authenticated account
11
+ # @param context [Hash] context about the sign-in
12
+ # - :mechanism [String] "password", "passwordless", or "social"
13
+ # - :provider [String, nil] e.g. "google", "apple", or nil
14
+ # - :first_sign_in [Boolean] whether this is the account's first browser session
15
+ # @return [void]
16
+ # @raise [StandardId::AuthenticationDenied] when hook returns { error: "..." }
17
+ def invoke_before_sign_in(account, context)
18
+ hook = StandardId.config.before_sign_in
19
+ return unless hook.respond_to?(:call)
20
+
21
+ context = context.merge(first_sign_in: first_sign_in?(account, session_created: false))
22
+ result = hook.call(account, request, context)
23
+
24
+ if result.is_a?(Hash) && result[:error].present?
25
+ raise StandardId::AuthenticationDenied, result[:error]
26
+ end
27
+ end
28
+
7
29
  # Invoke the after_sign_in hook if configured.
8
30
  #
9
31
  # @param account [Object] the authenticated account
10
32
  # @param context [Hash] context about the sign-in
11
- # - :connection [String] "email", "password", or "social"
33
+ # - :mechanism [String] "password", "passwordless", or "social"
12
34
  # - :provider [String, nil] e.g. "google", "apple", or nil
13
35
  # - :first_sign_in [Boolean] whether this is the account's first browser session
36
+ # - :session [StandardId::Session] the session that was just created
14
37
  # @return [String, nil] redirect path override, or nil for default
15
38
  # @raise [StandardId::AuthenticationDenied] to reject the sign-in
16
39
  def invoke_after_sign_in(account, context)
17
40
  hook = StandardId.config.after_sign_in
18
41
  return nil unless hook.respond_to?(:call)
19
42
 
20
- context = context.merge(first_sign_in: first_sign_in?(account))
43
+ context = context.merge(
44
+ first_sign_in: first_sign_in?(account, session_created: true),
45
+ session: session_manager.current_session
46
+ )
21
47
  hook.call(account, request, context)
22
48
  end
23
49
 
@@ -36,9 +62,12 @@ module StandardId
36
62
  end
37
63
 
38
64
  # Determine if this is the account's first browser session.
39
- # A count of 1 means the session just created is the only one.
40
- def first_sign_in?(account)
41
- account.sessions.where(type: "StandardId::BrowserSession").active.count <= 1
65
+ # When called before session creation (before_sign_in), count == 0 means first.
66
+ # When called after session creation (after_sign_in), count <= 1 means first
67
+ # (the just-created session is the only one).
68
+ def first_sign_in?(account, session_created: true)
69
+ active_count = account.sessions.where(type: "StandardId::BrowserSession").active.count
70
+ session_created ? active_count <= 1 : active_count == 0
42
71
  end
43
72
 
44
73
  # Handle AuthenticationDenied by revoking the session and redirecting to login.
@@ -48,7 +77,7 @@ module StandardId
48
77
  # @param account [Object, nil] the account to clean up if newly created
49
78
  # @param newly_created [Boolean] whether the account was created during this request
50
79
  def handle_authentication_denied(error, account: nil, newly_created: false)
51
- session_manager.revoke_current_session!
80
+ session_manager.revoke_current_session! if session_manager.current_session.present?
52
81
  destroy_newly_created_account(account) if newly_created
53
82
  message = error.message
54
83
  # When raised without arguments, StandardError#message returns the class name
@@ -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
@@ -59,7 +59,7 @@ module StandardId
59
59
  session.delete(:return_to_after_authenticating) || "/"
60
60
  end
61
61
 
62
- def sign_in_account(login_params)
62
+ def sign_in_account(login_params, &before_session)
63
63
  login = login_params[:email] || login_params[:login] # support both :email and :login keys
64
64
  password = login_params[:password]
65
65
  remember_me = ActiveModel::Type::Boolean.new.cast(login_params[:remember_me])
@@ -94,6 +94,10 @@ module StandardId
94
94
  credential_id: password_credential.id
95
95
  )
96
96
 
97
+ # Allow callers to run before_sign_in hooks after credential verification
98
+ # but before session creation. The block may raise AuthenticationDenied.
99
+ before_session&.call(password_credential.account)
100
+
97
101
  session_manager.sign_in_account(password_credential.account)
98
102
  session_manager.set_remember_cookie(password_credential) if remember_me
99
103
 
@@ -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
 
@@ -37,6 +37,8 @@ module StandardId
37
37
  account = find_or_create_account_from_social(social_info)
38
38
  end
39
39
  newly_created = account.previously_new_record?
40
+
41
+ invoke_before_sign_in(account, { mechanism: "social", provider: provider.provider_name })
40
42
  session_manager.sign_in_account(account)
41
43
 
42
44
  provider_name = provider.provider_name
@@ -50,7 +52,7 @@ module StandardId
50
52
  original_request_params: state_data
51
53
  )
52
54
 
53
- context = { connection: "social", provider: provider_name }
55
+ context = { mechanism: "social", provider: provider_name }
54
56
  redirect_override = invoke_after_sign_in(account, context)
55
57
 
56
58
  destination = redirect_override || state_data["redirect_uri"]
@@ -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]
@@ -39,8 +54,12 @@ module StandardId
39
54
  def handle_password_login
40
55
  return head(:not_found) unless StandardId.config.web.password_login
41
56
 
42
- if sign_in_account(login_params)
43
- context = { connection: "password", provider: nil }
57
+ result = sign_in_account(login_params) { |account|
58
+ invoke_before_sign_in(account, { mechanism: "password", provider: nil })
59
+ }
60
+
61
+ if result
62
+ context = { mechanism: "password", provider: nil }
44
63
  redirect_override = invoke_after_sign_in(current_account, context)
45
64
  destination = redirect_override || params[:redirect_uri] || after_authentication_url
46
65
  redirect_to destination, status: :see_other, notice: "Successfully signed in"
@@ -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?
@@ -42,12 +50,17 @@ module StandardId
42
50
  account = result.account
43
51
  newly_created = account.previously_new_record?
44
52
 
53
+ invoke_before_sign_in(account, { mechanism: "passwordless", provider: nil })
54
+
45
55
  session_manager.sign_in_account(account)
46
56
  emit_authentication_succeeded(account)
47
57
 
48
- invoke_after_account_created(account, { mechanism: "passwordless", provider: nil }) if newly_created
58
+ if newly_created
59
+ emit_passwordless_account_created(account)
60
+ invoke_after_account_created(account, { mechanism: "passwordless", provider: nil })
61
+ end
49
62
 
50
- context = { connection: @otp_data[:connection], provider: nil }
63
+ context = { mechanism: "passwordless", provider: nil }
51
64
  redirect_override = invoke_after_sign_in(account, context)
52
65
 
53
66
  session.delete(:standard_id_otp_payload)
@@ -81,6 +94,19 @@ module StandardId
81
94
  end
82
95
  end
83
96
 
97
+ def passwordless_registration_enabled?
98
+ StandardId.config.web.passwordless_registration
99
+ end
100
+
101
+ def emit_passwordless_account_created(account)
102
+ StandardId::Events.publish(
103
+ StandardId::Events::PASSWORDLESS_ACCOUNT_CREATED,
104
+ account: account,
105
+ channel: @otp_data[:connection],
106
+ identifier: @otp_data[:username]
107
+ )
108
+ end
109
+
84
110
  def emit_authentication_succeeded(account)
85
111
  StandardId::Events.publish(
86
112
  StandardId::Events::AUTHENTICATION_SUCCEEDED,
@@ -39,10 +39,11 @@ module StandardId
39
39
  form = StandardId::Web::SignupForm.new(signup_params)
40
40
 
41
41
  if form.submit
42
+ invoke_before_sign_in(form.account, { mechanism: "password", provider: nil })
42
43
  session_manager.sign_in_account(form.account)
43
44
  invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
44
45
 
45
- context = { connection: "password", provider: nil }
46
+ context = { mechanism: "password", provider: nil }
46
47
  redirect_override = invoke_after_sign_in(form.account, context)
47
48
  destination = redirect_override || params[:redirect_uri] || after_authentication_url
48
49
  redirect_to destination, notice: "Account created successfully"
@@ -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.
@@ -17,11 +17,11 @@
17
17
  "note": "Auth engine intentionally redirects to params[:redirect_uri] when user is not authenticated"
18
18
  },
19
19
  {
20
- "fingerprint": "68be03a57d3ef2cfb68582fc78ac2eb6b96aaa0a9897a9a975c24b889fdbb2aa",
20
+ "fingerprint": "1fe5fcac2c90d0480ef08f002ad04041eec2b95caabbc4e8b5d6cccc23c9283f",
21
21
  "note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
22
22
  },
23
23
  {
24
- "fingerprint": "277cf277d1c94f46d0abaeba9c51312d1d17e6f62c2e8d457dda47a6aad422aa",
24
+ "fingerprint": "413b5740add2c365198a540734a9e8c12b1c0483f521e2db145a80f3d582a4cc",
25
25
  "note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
26
26
  }
27
27
  ],
@@ -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.
@@ -24,17 +24,26 @@ StandardConfig.schema.draw do
24
24
 
25
25
  # Post-authentication lifecycle hooks (synchronous, WebEngine only)
26
26
  #
27
+ # after_account_created: Called after a new account is created via any mechanism.
28
+ # Receives: (account, request, context)
29
+ # Context: { mechanism: "passwordless"/"social"/"signup", provider: nil/"google"/"apple" }
30
+ field :after_account_created, type: :any, default: nil
31
+
32
+ # before_sign_in: Called after credential verification, BEFORE session creation.
33
+ # Receives: (account, request, context)
34
+ # Context: { mechanism: "password"/"passwordless"/"social", provider: nil/"google"/"apple",
35
+ # first_sign_in: bool }
36
+ # Return: nil or truthy to proceed with sign-in.
37
+ # Return { error: "message" } Hash to reject sign-in (error message is passed to the error flow).
38
+ field :before_sign_in, type: :any, default: nil
39
+
27
40
  # after_sign_in: Called after successful sign-in, before redirect.
28
41
  # Receives: (account, request, context)
29
- # Context: { first_sign_in: bool, connection: "email"/"password"/"social", provider: nil/"google"/"apple" }
42
+ # Context: { first_sign_in: bool, mechanism: "password"/"passwordless"/"social",
43
+ # provider: nil/"google"/"apple", session: StandardId::Session }
30
44
  # Return: nil (default redirect) or a path string (override redirect)
31
45
  # Raise StandardId::AuthenticationDenied.new("message") to reject sign-in.
32
46
  field :after_sign_in, type: :any, default: nil
33
-
34
- # after_account_created: Called after a new account is created via any mechanism.
35
- # Receives: (account, request, context)
36
- # Context: { mechanism: "passwordless"/"social"/"signup", provider: nil/"google"/"apple" }
37
- field :after_account_created, type: :any, default: nil
38
47
  end
39
48
 
40
49
  scope :events do
@@ -50,6 +59,20 @@ StandardConfig.schema.draw do
50
59
  field :max_attempts, type: :integer, default: 3
51
60
  field :retry_delay, type: :integer, default: 30 # 30 seconds
52
61
  field :bypass_code, type: :string, default: nil # E2E testing only — NEVER set in production
62
+
63
+ # Custom account factory for passwordless registration.
64
+ # When set, replaces the default find_or_create_account! logic in strategies.
65
+ # Must be a callable (lambda/proc) that receives (identifier:, params:, request:)
66
+ # and returns an Account (or account-like) record.
67
+ # When nil (default), uses the built-in strategy behavior.
68
+ field :account_factory, type: :any, default: nil
69
+
70
+ # OTP email delivery mode:
71
+ # :custom — (default) host app handles delivery via event subscriber
72
+ # :built_in — engine sends OTP emails automatically using PasswordlessMailer
73
+ field :delivery, type: :symbol, default: :custom
74
+ field :mailer_from, type: :string, default: "noreply@example.com"
75
+ field :mailer_subject, type: :string, default: "Your sign-in code"
53
76
  end
54
77
 
55
78
  scope :password do
@@ -90,6 +113,12 @@ StandardConfig.schema.draw do
90
113
  # Asymmetric (RSA): :rs256, :rs384, :rs512
91
114
  # Asymmetric (ECDSA): :es256, :es384, :es512
92
115
  field :signing_algorithm, type: :symbol, default: :hs256
116
+
117
+ # Custom claims callable for encoding additional claims into JWT access tokens.
118
+ # Receives keyword arguments: account:, client:, request:, audience:
119
+ # Must return a Hash of custom claims to merge into the JWT payload.
120
+ # Example: ->(account:, **) { { channel_id: account.channel_id } }
121
+ field :custom_claims, type: :any, default: nil
93
122
  end
94
123
 
95
124
  scope :social do
@@ -108,5 +137,25 @@ StandardConfig.schema.draw do
108
137
  field :email_verification, type: :boolean, default: true
109
138
  field :phone_verification, type: :boolean, default: true
110
139
  field :sessions_management, type: :boolean, default: true
140
+ field :passwordless_registration, type: :boolean, default: false
141
+ end
142
+
143
+ # Rate limiting defaults (used by Rails 8 built-in rate_limit DSL)
144
+ scope :rate_limits do
145
+ # RAR-51: Password login
146
+ field :password_login_per_ip, type: :integer, default: 20 # per 15 minutes
147
+ field :password_login_per_email, type: :integer, default: 5 # per 15 minutes
148
+
149
+ # RAR-60: OTP verification
150
+ field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
151
+
152
+ # RAR-56: Email/phone verification code generation
153
+ field :verification_start_per_target, type: :integer, default: 3 # per 15 minutes
154
+ field :verification_start_per_ip, type: :integer, default: 10 # per hour
155
+
156
+ # API equivalents
157
+ field :api_passwordless_start_per_ip, type: :integer, default: 10 # per hour
158
+ field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
159
+ field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
111
160
  end
112
161
  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.11.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.11.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