Releases: rarebit-one/standard_id
Release list
v0.29.1
Fixed
-
Signing in during an OAuth flow returned a 500 when
use_inertiais on.
Withconfig.use_inertia = true, the WebEngine's auth pages are Inertia
components, so their form submits are Inertia XHRs. The three
post-authentication redirects — passwordless OTP verify, password login, and
signup — used a plainredirect_toto send the user on to whatever was
stashed insession[:return_to_after_authenticating]. When that destination
is a non-Inertia controller (the ApiEngine's/api/authorizein an OAuth
round-trip), the Inertia client follows the redirect with theX-Inertia
header still attached, andinertia_rails' middleware raises
NoMethodError: undefined method 'inertia_configuration'— a 500 that broke
MCP/OAuth sign-in outright.The root asymmetry:
inertia_railsmixes its controller module in via
on_load(:action_controller_base), soWeb::BaseController
(ActionController::Base) getsinertia_configurationwhile
Api::BaseController(ActionController::API) never does.All three sites now route through a shared
redirect_after_authentication
helper onWeb::BaseController, which uses the existing
InertiaSupport#redirect_with_inertia— already used for social login and
signup — to emit a 409 +X-Inertia-Locationso the browser performs a real
page visit. Note this is keyed on the request being Inertia, not on the
destination being cross-origin: the destination in the OAuth case is
same-origin, so an external-only check would not have fixed it.The flash notice is now written before redirecting so it survives the
Inertia branch (which cannot carryredirect_to'snotice:option). -
Post-auth redirects to allow-listed deep links raised
UnsafeRedirectError.safe_destination?admits configured non-HTTP
schemes (e.g.myapp://), butredirect_torejects them without
allow_other_host:. Now passed conditionally — mirroring
ProvidersController— so Rails' same-origin backstop still applies to
everything else.safe_destination?itself is unchanged.
v0.29.0
Fixed
- A rate-limited GET no longer drives an unbounded redirect loop. The shared
rate-limit handler bounced every tripped action torequest.path. That is
safe for a POST/PATCH (its sibling GET is a different action), but v0.28.0
shipped the first rate-limited GETs — the email/phone verification-code
confirm#showactions — so a tripped GET redirected to itself: the
browser followed the redirect, re-incremented the counter, and got redirected
again, an unbounded loop the victim's browser drives that also permanently
resets the window. The handler now renders a terminal429 Too Many Requests
on GET/HEAD (no redirect), and keeps the existing same-path redirect for
non-GET web actions. API responses are unchanged. - The
Retry-Afterheader now reflects the tripped limit's real window. It
was hardcoded to 15 minutes, which was 4x too short for every 1-hour limit
(verification_start_per_ip,password_reset_start_per_ip,signup_per_ip,
api_passwordless_start_per_ip,dynamic_registration_per_ip). Each
rate_limitdeclaration'swithin:is now threaded to the handler (captured
in the per-limitwith:closure the instant that limit trips, since
ActionController::TooManyRequestscarries no window and a controller may
declare several limits with different windows), soRetry-Aftermatches the
window that actually fired. A hand-rolledraisethat bypasses the macro
falls back to 15 minutes. - Blank per-target rate-limit keys no longer collapse into one shared bucket.
Five per-target limiters interpolated a param that can be blank, yielding a
stable key like"reset-password:"— a single global bucket (Rails'
.compactdoes not drop a non-nil empty string), so blank-target spam from
one source throttled every legitimate user. Affected: web login (by email),
API passwordless start (by target), email/phone verification start (by
target), and password-reset start (by email). Each now falls the key back to
the remote IP when the target is blank, keeping blank spam bounded per-IP
without poisoning real targets' buckets — mirroring the existing per-audience
token limiter's "only count well-formed values" shape.
Added
- Mechanism-agnostic login rate-limit config:
rate_limits.login_per_ip
(default 20) andrate_limits.login_per_email(default 5). The login
#createaction branches password OR passwordless, so on a passwordless app
the existingpassword_login_per_*fields actually govern the OTP-send limit
— a misnomer that led four consumer apps to write false config comments. The
new names describe the action, not a mechanism. When unset they inherit the
deprecatedpassword_login_*values, so existing behaviour is unchanged; when
set they win.
Deprecated
rate_limits.password_login_per_ip/rate_limits.password_login_per_email
are deprecated in favour oflogin_per_ip/login_per_email. They remain
fully honoured (the config schema rejects unknown fields at boot, so a host
still setting the old names keeps working); the login controller reads the new
alias and falls back to the deprecated field when the alias is left at its
default. Follows themax_attempts→max_attempts_per_challengealias
precedent.
v0.28.0
Added
- Rate limiting on the last unprotected auth surfaces. The web
password-reset request (reset_password/start), password signup, and the
email/phone code-confirmation endpoints now carry Rails-native
rate_limits, closing email-flooding, account-enumeration,
account-creation-spam, and distributed code-guessing gaps. New config keys:
rate_limits.password_reset_start_per_ip(10/hr),
rate_limits.password_reset_start_per_target(3/15min), and
rate_limits.signup_per_ip(10/hr); the confirm endpoints reuse
otp_verify_per_ip. All use the existingRateLimitStore+ centralized 429
handler. - The
passwordless.retry_delayOTP-resend cooldown is now enforced.
Previously the setting existed but did nothing. A resend for the same target
within the window (default 30s) is rejected with anInvalidRequestError;
the already-issued code stays valid. Setretry_delay = 0to disable.
v0.27.0
Added
- Loopback redirect URIs match on any port for public PKCE clients (RFC 8252
§7.3). Native apps receive the authorization response on an ephemeral
local listener whose port cannot be known at registration time. When a
public client withrequire_pkcepresents a redirect URI whose scheme is
httpand whose host is a loopback literal (127.0.0.1,::1, or
localhost), and a registered redirect URI is likewise a loopback URI, the
comparison now ignores the port — host and path must still match exactly, so
127.0.0.1andlocalhostdo not cross-match (RFC 8252 §8.3 recommends the
IP literals overlocalhost). Confidential clients and non-loopback URIs
keep strict scheme+host+port+path matching. Registration-time validation is
unchanged. Token exchange is unaffected: the redirect URI presented at the
token endpoint is still compared byte-for-byte against the value stored when
the code was issued.
v0.26.4
Fixed
- WebEngine controller redirects no longer drop the mount prefix on non-root
mounts. Isolated-engine_pathhelpers are mount-relative, and
redirect_to/redirect_with_inertia(unlikeform_with/url_forview
URL generation) do not prepend the mount'sSCRIPT_NAME. So when the engine
was mounted at a non-root path (e.g.mount StandardId::WebEngine => "/auth"),
redirects produced prefix-less paths that 404'd — most visiblyPOST /auth/loginredirecting to/login_verifyinstead of/auth/login_verify,
breaking passwordless login. Form actions were never affected. All affected
redirects (login→login_verify;login_verify/ reset-password →
login; session revoke →sessions; account update →account; and the
after_sign_indenial bounce) now prependrequest.script_namevia a new
engine_pathhelper (a no-op for root mounts). Regression test added to
spec/integration/multi_mount_spec.rb.
v0.26.3
Fixed
- Unauthenticated access to a protected web page no longer 500s. The web
authentication guard (require_browser_session!) raises
NotAuthenticatedError/InvalidSessionError(for missing / expired /
revoked sessions) rather than redirecting. The API base controller rescued
these, but the web base controller did not — so an unauthenticated request to
a protected web page (e.g./sessions) surfaced as a 500 instead of bouncing
to login. The web base controller now rescues both and redirects to the login
page, preserving the original destination.
v0.26.2
Security
- OTP codes and password-reset tokens no longer leak into the logs. The
built-in mailers (PasswordlessMailer,PasswordResetMailer) pass the OTP
code / reset URL as mailer params, whichdeliver_laterserializes as the
delivery job's arguments — and ActiveJob's log subscriber prints job arguments
in plaintext on enqueue/perform (e.g.params: {email:…, otp_code: "03158369"}).
StandardId's mailers now deliver viaStandardId::SecureMailDeliveryJob
(ActionMailer::MailDeliveryJobwithlog_arguments = false), so the
arguments are kept out of the log stream. Delivery behaviour is unchanged.
v0.26.1
Fixed
- Passwordless
login_verifyOTP input now respects
config.passwordless.code_length. The built-in ERB verification-code field
hardcodedmaxlength: 6, so apps configuring a longer code (e.g.
code_length = 8) rendered a field that truncated input to 6 characters —
users could not enter the full code. The input now derivesmaxlengthfrom
StandardId::Passwordless.otp_code_length(the same clamped 4..10 value the
OTP generator uses), exposed to views via a new
StandardId::ApplicationHelper#otp_code_lengthhelper, so the form and the
generated code stay in sync end-to-end. - WebEngine rate-limit responses no longer 500 on hosts without a root
route. When a web auth action (login, login_verify, email/phone verification
start) hit its rate limit, the handler redirected to
request.referer || main_app.root_path. That raised — and returned a 500
error page instead of the intended graceful response — for any host app that
doesn't define arootroute (e.g. an API/control-plane that only mounts the
engine), and also for cross-originRefererheaders (Rails' open-redirect
guard). The handler now redirects back to the rate-limited form's own path
(request.path), which is always a valid same-origin GET. The ApiEngine
responses (JSON429) are unchanged. - OAuth/OIDC metadata no longer advertises a
jwks_uriunder symmetric
signing. With the default HS256 (and HS384/HS512) there are no public keys
to publish, so the JWKS endpoint deliberately returns 404 — but the
authorization-server and openid-configuration documents advertisedjwks_uri
unconditionally, pointing clients at a dead URL.jwks_uriis now emitted only
when signing is asymmetric (RS*/ES*). RFC 8414 makes it optional; HS-signed
tokens are verified with the shared secret, not JWKS. - Sign-out / unauthenticated requests no longer leave a non-HttpOnly
session_tokencookie.clear_session!assigned
cookies.encrypted[:session_token] = nil, which wrote a fresh encrypted blob
through the cookie jar's default options (noHttpOnly) on every
unauthenticated request. It now usescookies.delete(:session_token)to remove
the cookie cleanly. The token-bearing sign-in write was alreadyhttponly: true
and is unchanged, so this is a hygiene/consistency fix, not a token exposure.
v0.26.0
Added
config.passwordless.production_env_detector— an optional callable that
decides whether the current deploy counts as "production" for the bypass-code
guard. Whennil(default) the gem falls back toRails.env.production?, so
existing consumers are unchanged. Apps that distinguish a physical deploy
environment fromRAILS_ENV(e.g. a staging box still running
RAILS_ENV=production) can supply-> { AppEnv.production? }to permit a
bypass_codeon staging while it stays refused on real production. The guard
inPasswordless::VerificationService(which also backsOtp.verify) now
defers to this detector instead of checkingRails.env.production?directly.
v0.25.0
Changed
- Browser session cookie now persists across browser restarts. The
encryptedsession_tokencookie is written with an explicitexpirestied
to theBrowserSession#expires_at(previously a bare session cookie that was
cleared on full browser close, logging users out well before their session
actually expired). The cookie is also hardened withhttponly: true,
same_site: :lax, andsecurefollowingrequest.ssl?. Combined with a
host-configuredsession.browser_session_lifetime, this lets a "remember me
for N days" session survive closing and reopening the browser. Applies to
both the sign-in and remember-token re-auth paths.