standard_id 0.8.1 → 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 (40) 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/forms/standard_id/web/reset_password_confirm_form.rb +1 -1
  13. data/app/forms/standard_id/web/signup_form.rb +2 -1
  14. data/app/mailers/standard_id/passwordless_mailer.rb +16 -0
  15. data/app/models/concerns/standard_id/password_strength.rb +31 -0
  16. data/app/models/standard_id/authorization_code.rb +29 -10
  17. data/app/models/standard_id/password_credential.rb +2 -1
  18. data/app/models/standard_id/refresh_token.rb +9 -14
  19. data/app/models/standard_id/session.rb +7 -5
  20. data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +24 -0
  21. data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +12 -0
  22. data/db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb +7 -0
  23. data/lib/generators/standard_id/install/templates/standard_id.rb +5 -0
  24. data/lib/standard_id/config/schema.rb +43 -3
  25. data/lib/standard_id/engine.rb +1 -0
  26. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +37 -0
  27. data/lib/standard_id/jwt_service.rb +4 -3
  28. data/lib/standard_id/oauth/refresh_token_flow.rb +54 -24
  29. data/lib/standard_id/oauth/token_grant_flow.rb +17 -1
  30. data/lib/standard_id/passwordless/base_strategy.rb +34 -2
  31. data/lib/standard_id/passwordless/email_strategy.rb +7 -0
  32. data/lib/standard_id/passwordless/sms_strategy.rb +5 -0
  33. data/lib/standard_id/passwordless/verification_service.rb +56 -16
  34. data/lib/standard_id/passwordless.rb +56 -0
  35. data/lib/standard_id/rate_limit_store.rb +42 -0
  36. data/lib/standard_id/testing/factories/credentials.rb +2 -2
  37. data/lib/standard_id/version.rb +1 -1
  38. data/lib/standard_id.rb +3 -0
  39. metadata +11 -2
  40. /data/db/migrate/{20260311000000_create_standard_id_refresh_tokens.rb → 20260311100000_create_standard_id_refresh_tokens.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6e84ca4e7d4d01aa3957ed1371ef774fc1203b9006da7591b99e20fadb343b4
4
- data.tar.gz: cc74bfcf5f471d4bf7b76d621497598fac95fa0f833b256a74c3a4374456850b
3
+ metadata.gz: 7ad192cf3b1bad92d1ec8322e203804401d8c10e54ebfcde4340f7840fa624bd
4
+ data.tar.gz: 0eb0cc7c613bd3fdd981818c6d4fd359f809003da34d6e15f5e2fea9fd0eab74
5
5
  SHA512:
6
- metadata.gz: 21932b0dd06f986340284587ccfae60cfea9b0e5075cdc670ff297e23412c0440d0e434e3974f98f65b9071fb15a118c3c36920e08b8887e9857fa612318e857
7
- data.tar.gz: 7acf89c15d302da9e25da25de4c786441df085f65950a02e8b6fec71b4a90cf3f72c3167929371018869cc4787e6476d5df78036e3b2dde4833525d5c260045f
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
@@ -3,6 +3,7 @@ module StandardId
3
3
  class ResetPasswordConfirmForm
4
4
  include ActiveModel::Model
5
5
  include ActiveModel::Attributes
6
+ include StandardId::PasswordStrength
6
7
 
7
8
  attribute :password, :string
8
9
  attribute :password_confirmation, :string
@@ -11,7 +12,6 @@ module StandardId
11
12
 
12
13
  validates :password,
13
14
  presence: { message: "cannot be blank" },
14
- length: { minimum: 8, too_short: "must be at least 8 characters long" },
15
15
  confirmation: { message: "confirmation doesn't match" }
16
16
 
17
17
  def initialize(password_credential, params = {})
@@ -3,6 +3,7 @@ module StandardId
3
3
  class SignupForm
4
4
  include ActiveModel::Model
5
5
  include ActiveModel::Attributes
6
+ include StandardId::PasswordStrength
6
7
 
7
8
  attribute :email, :string
8
9
  attribute :password, :string
@@ -11,7 +12,7 @@ module StandardId
11
12
  attr_reader :account
12
13
 
13
14
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
14
- validates :password, presence: true, length: { minimum: 8 }, confirmation: true
15
+ validates :password, presence: true, confirmation: true
15
16
 
16
17
  def submit
17
18
  return false unless valid?
@@ -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,31 @@
1
+ module StandardId
2
+ module PasswordStrength
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ validate :password_meets_strength_requirements, if: -> { password.present? }
7
+ end
8
+
9
+ private
10
+
11
+ def password_meets_strength_requirements
12
+ config = StandardId.config.password
13
+
14
+ if password.length < config.minimum_length
15
+ errors.add(:password, "must be at least #{config.minimum_length} characters long")
16
+ end
17
+
18
+ if config.require_uppercase && password !~ /[A-Z]/
19
+ errors.add(:password, "must include at least one uppercase letter")
20
+ end
21
+
22
+ if config.require_numbers && password !~ /\d/
23
+ errors.add(:password, "must include at least one number")
24
+ end
25
+
26
+ if config.require_special_chars && password !~ /[^a-zA-Z0-9]/
27
+ errors.add(:password, "must include at least one special character")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -17,6 +17,20 @@ module StandardId
17
17
  before_validation :set_issued_and_expiry, on: :create
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
+ # Fail fast: reject unsupported PKCE methods at issuance rather than
21
+ # storing a code that will always fail at redemption time.
22
+ if code_challenge.present?
23
+ unless code_challenge_method.to_s.downcase == "s256"
24
+ raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed"
25
+ end
26
+ end
27
+
28
+ # Hash the code_challenge for defense-in-depth (RAR-58).
29
+ # The stored value is SHA256(S256_challenge), where S256_challenge is
30
+ # base64url(SHA256(verifier)). This is intentionally a double-hash:
31
+ # S256 derives the challenge from the verifier, and we hash again for storage.
32
+ hashed_challenge = code_challenge.present? ? Digest::SHA256.hexdigest(code_challenge) : nil
33
+
20
34
  create!(
21
35
  account: account,
22
36
  code_hash: hash_for(plaintext_code),
@@ -24,7 +38,7 @@ module StandardId
24
38
  redirect_uri: redirect_uri,
25
39
  scope: scope,
26
40
  audience: audience,
27
- code_challenge: code_challenge,
41
+ code_challenge: hashed_challenge,
28
42
  code_challenge_method: code_challenge_method,
29
43
  nonce: nonce,
30
44
  issued_at: Time.current,
@@ -58,15 +72,20 @@ module StandardId
58
72
 
59
73
  return false if code_verifier.blank?
60
74
 
61
- case (code_challenge_method || "plain").downcase
62
- when "s256"
63
- expected = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).delete("=")
64
- ActiveSupport::SecurityUtils.secure_compare(expected, code_challenge)
65
- when "plain"
66
- ActiveSupport::SecurityUtils.secure_compare(code_verifier, code_challenge)
67
- else
68
- false
69
- end
75
+ # Only S256 is supported (OAuth 2.1). The "plain" method is rejected
76
+ # because it transmits the verifier in cleartext, defeating PKCE's purpose.
77
+ return false unless (code_challenge_method || "").downcase == "s256"
78
+
79
+ s256_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).delete("=")
80
+
81
+ # New format: stored value is SHA256(S256_challenge)
82
+ hashed_expected = Digest::SHA256.hexdigest(s256_challenge)
83
+ return true if ActiveSupport::SecurityUtils.secure_compare(hashed_expected, code_challenge)
84
+
85
+ # Legacy fallback: codes issued before RAR-58 store the raw S256 challenge.
86
+ # This handles in-flight codes during deployment (max 10-minute TTL).
87
+ # Safe to remove after one deployment cycle.
88
+ ActiveSupport::SecurityUtils.secure_compare(s256_challenge, code_challenge)
70
89
  end
71
90
 
72
91
  def mark_as_used!
@@ -1,6 +1,7 @@
1
1
  module StandardId
2
2
  class PasswordCredential < ApplicationRecord
3
3
  include StandardId::Credentiable
4
+ include StandardId::PasswordStrength
4
5
 
5
6
  has_secure_password
6
7
 
@@ -13,7 +14,7 @@ module StandardId
13
14
  end
14
15
 
15
16
  validates :login, presence: true, uniqueness: true
16
- validates :password, length: { minimum: 8 }, confirmation: true, if: :validate_password?
17
+ validates :password, confirmation: true, if: :validate_password?
17
18
 
18
19
  private
19
20
 
@@ -34,7 +34,8 @@ module StandardId
34
34
  end
35
35
 
36
36
  def revoke!
37
- update!(revoked_at: Time.current) unless revoked?
37
+ rows = self.class.where(id: id, revoked_at: nil).update_all(revoked_at: Time.current)
38
+ reload if rows > 0
38
39
  end
39
40
 
40
41
  # Revoke this token and all tokens in the same family chain.
@@ -45,22 +46,18 @@ module StandardId
45
46
  family_tokens.where(revoked_at: nil).update_all(revoked_at: Time.current)
46
47
  end
47
48
 
48
- # Max depth guard to prevent unbounded traversal in case of
49
- # corrupted data or extremely long-lived token chains.
50
- MAX_FAMILY_DEPTH = 50
51
-
52
49
  private
53
50
 
54
51
  # Find the root of this token's family and return all descendants.
55
- # Uses iterative queries (one per generation) bounded by MAX_FAMILY_DEPTH.
56
- # For typical token chain lengths (<10) this is fine; a recursive CTE
57
- # would collapse to a single query if performance becomes a concern.
52
+ # Backward traversal uses a visited set for cycle detection in case
53
+ # of corrupted data. Forward traversal collects all descendants.
58
54
  def family_tokens
59
55
  root = self
60
- depth = 0
61
- while root.previous_token.present? && depth < MAX_FAMILY_DEPTH
56
+ visited = Set.new([root.id])
57
+ while root.previous_token.present?
58
+ break if visited.include?(root.previous_token_id)
59
+ visited.add(root.previous_token_id)
62
60
  root = root.previous_token
63
- depth += 1
64
61
  end
65
62
 
66
63
  self.class.where(id: collect_family_ids(root.id))
@@ -69,15 +66,13 @@ module StandardId
69
66
  def collect_family_ids(root_id)
70
67
  ids = [root_id]
71
68
  current_ids = [root_id]
72
- depth = 0
73
69
 
74
- while depth < MAX_FAMILY_DEPTH
70
+ loop do
75
71
  next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
76
72
  break if next_ids.empty?
77
73
 
78
74
  ids.concat(next_ids)
79
75
  current_ids = next_ids
80
- depth += 1
81
76
  end
82
77
 
83
78
  ids
@@ -7,7 +7,7 @@ module StandardId
7
7
  belongs_to :account, class_name: StandardId.config.account_class_name
8
8
  has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :nullify
9
9
 
10
- before_destroy :revoke_active_refresh_tokens
10
+ before_destroy :revoke_active_refresh_tokens, prepend: true
11
11
 
12
12
  scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
13
13
  scope :expired, -> { where("expires_at <= ?", Time.current) }
@@ -38,10 +38,12 @@ module StandardId
38
38
 
39
39
  def revoke!(reason: nil)
40
40
  @reason = reason
41
- update!(revoked_at: Time.current)
42
- # Cascade revocation to refresh tokens. Uses update_all for efficiency;
43
- # intentionally skips updated_at since revocation is tracked via revoked_at.
44
- refresh_tokens.active.update_all(revoked_at: Time.current)
41
+ transaction do
42
+ update!(revoked_at: Time.current)
43
+ # Cascade revocation to refresh tokens. Uses update_all for efficiency;
44
+ # intentionally skips updated_at since revocation is tracked via revoked_at.
45
+ refresh_tokens.active.update_all(revoked_at: Time.current)
46
+ end
45
47
  end
46
48
 
47
49
  private
@@ -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.
@@ -0,0 +1,7 @@
1
+ class AddNullifyToRefreshTokenPreviousTokenFk < ActiveRecord::Migration[8.0]
2
+ def change
3
+ remove_foreign_key :standard_id_refresh_tokens, column: :previous_token_id
4
+ add_foreign_key :standard_id_refresh_tokens, :standard_id_refresh_tokens,
5
+ column: :previous_token_id, on_delete: :nullify
6
+ end
7
+ end
@@ -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,13 +50,27 @@ 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
56
70
  field :minimum_length, type: :integer, default: 8
57
- field :require_special_chars, type: :boolean, default: false
58
- field :require_uppercase, type: :boolean, default: false
59
- field :require_numbers, type: :boolean, default: false
71
+ field :require_special_chars, type: :boolean, default: true
72
+ field :require_uppercase, type: :boolean, default: true
73
+ field :require_numbers, type: :boolean, default: true
60
74
  end
61
75
 
62
76
  scope :session 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. " \