Module: StandardId::Otp

Defined in:
lib/standard_id/otp.rb

Overview

Public OTP primitive — issue and verify one-time codes for any realm.

Until now, OTP lifecycle logic lived inside the passwordless authentication flow. Host apps that needed OTP for other purposes (contact verification, widget confirmations, step-up challenges, etc.) had to call StandardId::CodeChallenge.create! directly and reimplement verification — usually missing enumeration defenses, atomic attempt tracking, and race-condition protection.

StandardId::Otp wraps the same hardened machinery used by the passwordless login flow (VerificationService + BaseStrategy) and parameterizes it by realm so consumers can use OTP safely for anything.

Issue

result = StandardId::Otp.issue(
  realm: "widget_contact_verification",
  target: "user@example.com",
  channel: :email,
  request: request,
  delivery: :manual
)
result.code        # the raw 6-digit code (only when delivery: :manual)
result.challenge   # StandardId::CodeChallenge record

Verify

result = StandardId::Otp.verify(
  realm: "widget_contact_verification",
  target: "user@example.com",
  channel: :email,
  code: params[:otp],
  request: request
)
result.success?    # boolean
result.error_code  # :invalid_code | :expired | :max_attempts | :not_found | :blank_code | :server_error
result.challenge   # consumed CodeChallenge on success, nil on failure (or bypass)

Delivery modes

  • :built_in — uses the engine’s bundled mailer (StandardId::PasswordlessMailer) when StandardId.config.passwordless.delivery is :built_in. Works for channel: :email only.

  • :custom — calls the configured passwordless_email_sender or passwordless_sms_sender callback.

  • :manual — skip delivery entirely; the raw code is returned on the result so the caller can deliver it however they like. Useful for custom widget/embedded flows that want full control over the channel.

Defined Under Namespace

Classes: IssueResult, NullRequest

Constant Summary collapse

VALID_CHANNELS =
%w[email sms].freeze
VALID_DELIVERIES =
%i[built_in custom manual].freeze
DEFAULT_REALM =

Canonical realm for authentication flows. Aliased to the Passwordless constant so the string lives in exactly one place.

StandardId::Passwordless::DEFAULT_REALM

Class Method Summary collapse

Class Method Details

.issue(realm:, target:, channel: :email, request: nil, code_length: nil, expires_in: nil, metadata: {}, delivery: :built_in) ⇒ IssueResult

Issue a new OTP in the given realm.

Parameters:

  • realm (String)

    Free-form realm name that partitions challenges by purpose (e.g. “authentication”, “widget_contact_verification”).

  • target (String)

    The recipient identifier — email address or phone number, depending on channel.

  • channel (Symbol, String) (defaults to: :email)

    :email or :sms.

  • request (ActionDispatch::Request, nil) (defaults to: nil)

    Current request. Used to stamp ip_address/user_agent on the challenge. Optional — when nil, a minimal null-object is used.

  • code_length (Integer, nil) (defaults to: nil)

    Number of digits (4..10). Defaults to 6.

  • expires_in (Integer, ActiveSupport::Duration, nil) (defaults to: nil)

    TTL. Defaults to StandardId.config.passwordless.code_ttl seconds.

  • metadata (Hash) (defaults to: {})

    Extra metadata to stamp on the challenge.

  • delivery (Symbol) (defaults to: :built_in)

    :built_in, :custom, or :manual (see above).

Returns:



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/standard_id/otp.rb', line 95

def issue(realm:, target:, channel: :email, request: nil, code_length: nil, expires_in: nil, metadata: {}, delivery: :built_in)
  channel_s    = channel.to_s
  realm_s      = realm.to_s
  delivery_sym = delivery.to_sym

  unless VALID_CHANNELS.include?(channel_s)
    raise StandardId::InvalidRequestError, "Unsupported channel: #{channel.inspect} (must be :email or :sms)"
  end
  unless VALID_DELIVERIES.include?(delivery_sym)
    raise StandardId::InvalidRequestError, "Unsupported delivery: #{delivery.inspect} (must be :built_in, :custom, or :manual)"
  end
  if realm_s.blank?
    # Raised (not returned as a failure result) because a blank realm is
    # a programmer error — mirrors Otp.verify for API consistency.
    raise StandardId::InvalidRequestError, "realm: is required"
  end
  if target.to_s.strip.blank?
    return failure_issue_result(:invalid_request, "target: is required")
  end

  # Fail loud when the caller asked for :custom delivery but has not
  # configured the corresponding sender callback. Without this guard
  # BaseStrategy#start! would silently skip delivery (the sender_callback
  # is nil and `&.call` no-ops) while Otp.issue still returned a success
  # result — making host apps believe the OTP was sent when it was not.
  assert_custom_sender_configured!(channel_s) if delivery_sym == :custom

  strategy = build_strategy(channel_s, request, realm: realm_s)

  begin
    challenge = strategy.start!(
      username: target,
      code_length: code_length,
      expires_in: normalize_expires_in(expires_in),
      metadata: ,
      skip_sender: delivery_sym == :manual
    )
  rescue StandardId::InvalidRequestError => e
    # Validation failures from the strategy (invalid email/phone format,
    # custom username_validator rejections, etc.) are surfaced as a
    # failed result instead of propagating, so callers can handle them
    # like any other OTP outcome.
    return failure_issue_result(:invalid_request, e.message)
  end

  IssueResult.new(
    success?: true,
    challenge: challenge,
    code: delivery_sym == :manual ? challenge.code : nil,
    error_code: nil,
    error_message: nil
  )
end

.verify(realm:, target:, code:, channel: :email, request: nil) ⇒ VerificationService::Result

Verify a previously issued OTP.

Delegates to VerificationService, which already handles:

  • Constant-time compare even when no challenge exists (enum defense)

  • Atomic failed-attempt tracking (PR #165)

  • Transactional lock to prevent concurrent reuse (PR #169)

When StandardId.config.passwordless.bypass_code is set (and we are not in production) and code matches it, verification succeeds without looking up a challenge. This is intentionally realm-scoped: any realm accepts the bypass code. Bypass is intended for E2E testing only. Never set bypass_code in production.

Parameters:

  • realm (String)

    The realm the challenge was issued in.

  • target (String)

    Recipient identifier.

  • channel (Symbol, String) (defaults to: :email)

    :email or :sms.

  • code (String)

    The submitted code.

  • request (ActionDispatch::Request, nil) (defaults to: nil)

Returns:

  • (VerificationService::Result)

    See VerificationService docs.



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/standard_id/otp.rb', line 168

def verify(realm:, target:, code:, channel: :email, request: nil)
  channel_s = channel.to_s
  realm_s   = realm.to_s

  unless VALID_CHANNELS.include?(channel_s)
    raise StandardId::InvalidRequestError, "Unsupported channel: #{channel.inspect} (must be :email or :sms)"
  end
  if realm_s.blank?
    raise StandardId::InvalidRequestError, "realm: is required"
  end

  req = request || NullRequest.new

  # For the "authentication" realm, retain legacy behavior: resolve
  # and return an Account so existing callers (passwordless login)
  # keep working. For any other realm, skip account resolution —
  # callers of Otp.verify are using the primitive for non-auth
  # flows (contact verification, widget confirms, step-up, etc.)
  # and should not need an Account to exist.
   = realm_s == DEFAULT_REALM

  kwargs = {
    code: code,
    request: req,
    realm: realm_s,
    resolve_account: 
  }
  if channel_s == "email"
    kwargs[:email] = target
  else
    kwargs[:phone] = target
  end

  StandardId::Passwordless::VerificationService.verify(**kwargs)
end