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) whenStandardId.config.passwordless.deliveryis:built_in. Works for channel: :email only. -
:custom— calls the configuredpasswordless_email_senderorpasswordless_sms_sendercallback. -
:manual— skip delivery entirely; the rawcodeis 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
-
.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.
-
.verify(realm:, target:, code:, channel: :email, request: nil) ⇒ VerificationService::Result
Verify a previously issued OTP.
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.
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.) 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.
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. resolve_account = realm_s == DEFAULT_REALM kwargs = { code: code, request: req, realm: realm_s, resolve_account: resolve_account } if channel_s == "email" kwargs[:email] = target else kwargs[:phone] = target end StandardId::Passwordless::VerificationService.verify(**kwargs) end |