standard_id 0.29.0 → 0.30.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.
data/CHANGELOG.md ADDED
@@ -0,0 +1,1038 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.30.0] - 2026-07-26
11
+
12
+ ### Added
13
+
14
+ - **`config.oauth.strict_redirect_uri_matching`** (default `false`). RFC 6749
15
+ §4.1.3 requires a `redirect_uri` that was present at `/authorize` to be
16
+ repeated, identically, at `/token`; the token endpoint previously only
17
+ compared the values when the client bothered to send one, so omitting the
18
+ parameter skipped the check entirely. Strictness is opt-in so the flip is
19
+ deliberate. While it is off, a code minted **with** a `redirect_uri` that is
20
+ redeemed **without** one logs a warning naming the `client_id` — watch for
21
+ it, then set the flag to `true`. Codes minted without a `redirect_uri` are
22
+ unaffected either way, and a *mismatched* value has always been (and remains)
23
+ rejected.
24
+
25
+ - **`config.oauth.revocation_scope`** (default `:account`, the existing
26
+ behaviour). `POST /oauth/revoke` bulk-revoked **every** active `DeviceSession`
27
+ for the token's subject regardless of which token was presented, so one
28
+ client's logout signed the account out everywhere. Setting it to `:grant`
29
+ narrows revocation to the authorization grant behind the presented token
30
+ (RFC 7009 §2.1): the refresh-token family its `jti` resolves to, plus that
31
+ grant's `Session` when one is linked. A stateless access token resolves to no
32
+ stored artifact and therefore revokes nothing under `:grant` (still `200`,
33
+ per RFC 7009 §2.2) — present the refresh token. An unrecognised value logs a
34
+ warning and falls back to `:account`. `ServiceSession`s remain untouched
35
+ under both settings. The `OAUTH_TOKEN_REVOKED` event payload gains a
36
+ `refresh_tokens_revoked` count alongside `sessions_revoked` (additive).
37
+
38
+ - **`Session.authenticate_by_token`** / **`Session#authenticate_token`**.
39
+ `Session.by_token` matches only the SHA256 `lookup_hash` — an index key, not
40
+ a credential — so every consumer hand-rolled the mandatory BCrypt verify
41
+ against `token_digest` (and the `BCrypt::Errors::InvalidHash` rescue). The
42
+ new entry point does the lookup and a constant-time digest comparison
43
+ (`ActiveSupport::SecurityUtils.secure_compare`), honours the current scope
44
+ (`Session.api_compatible.active.authenticate_by_token(token)`) and returns
45
+ `nil` rather than raising for blank, unknown, mismatched, or malformed-digest
46
+ tokens.
47
+
48
+ ### Fixed
49
+
50
+ - `CHANGELOG.md` is now included in the published gem. The gemspec's file glob
51
+ omitted it, so every release to date shipped without one even though the file
52
+ has always existed in the repo — the only gem in the `standard_*` family
53
+ missing it. No code change; `lib/`, `app/`, `config/`, and `db/` are unaffected.
54
+
55
+ ## [0.29.1] - 2026-07-16
56
+
57
+ ### Fixed
58
+
59
+ - **Signing in during an OAuth flow returned a 500 when `use_inertia` is on.**
60
+ With `config.use_inertia = true`, the WebEngine's auth pages are Inertia
61
+ components, so their form submits are Inertia XHRs. The three
62
+ post-authentication redirects — passwordless OTP verify, password login, and
63
+ signup — used a plain `redirect_to` to send the user on to whatever was
64
+ stashed in `session[:return_to_after_authenticating]`. When that destination
65
+ is a *non*-Inertia controller (the ApiEngine's `/api/authorize` in an OAuth
66
+ round-trip), the Inertia client follows the redirect with the `X-Inertia`
67
+ header still attached, and `inertia_rails`' middleware raises
68
+ `NoMethodError: undefined method 'inertia_configuration'` — a 500 that broke
69
+ MCP/OAuth sign-in outright.
70
+
71
+ The root asymmetry: `inertia_rails` mixes its controller module in via
72
+ `on_load(:action_controller_base)`, so `Web::BaseController`
73
+ (`ActionController::Base`) gets `inertia_configuration` while
74
+ `Api::BaseController` (`ActionController::API`) never does.
75
+
76
+ All three sites now route through a shared `redirect_after_authentication`
77
+ helper on `Web::BaseController`, which uses the existing
78
+ `InertiaSupport#redirect_with_inertia` — already used for social login and
79
+ signup — to emit a 409 + `X-Inertia-Location` so the browser performs a real
80
+ page visit. Note this is keyed on the *request* being Inertia, not on the
81
+ destination being cross-origin: the destination in the OAuth case is
82
+ same-origin, so an external-only check would not have fixed it.
83
+
84
+ The flash notice is now written before redirecting so it survives the
85
+ Inertia branch (which cannot carry `redirect_to`'s `notice:` option).
86
+
87
+ - **Post-auth redirects to allow-listed deep links raised
88
+ `UnsafeRedirectError`.** `safe_destination?` admits configured non-HTTP
89
+ schemes (e.g. `myapp://`), but `redirect_to` rejects them without
90
+ `allow_other_host:`. Now passed conditionally — mirroring
91
+ `ProvidersController` — so Rails' same-origin backstop still applies to
92
+ everything else. `safe_destination?` itself is unchanged.
93
+
94
+ ## [0.29.0] - 2026-07-15
95
+
96
+ ### Fixed
97
+
98
+ - **A rate-limited GET no longer drives an unbounded redirect loop.** The shared
99
+ rate-limit handler bounced every tripped action to `request.path`. That is
100
+ safe for a POST/PATCH (its sibling GET is a different action), but v0.28.0
101
+ shipped the first rate-limited GETs — the email/phone verification-code
102
+ *confirm* `#show` actions — so a tripped GET redirected to *itself*: the
103
+ browser followed the redirect, re-incremented the counter, and got redirected
104
+ again, an unbounded loop the victim's browser drives that also permanently
105
+ resets the window. The handler now renders a terminal `429 Too Many Requests`
106
+ on GET/HEAD (no redirect), and keeps the existing same-path redirect for
107
+ non-GET web actions. API responses are unchanged.
108
+ - **The `Retry-After` header now reflects the tripped limit's real window.** It
109
+ was hardcoded to 15 minutes, which was 4x too short for every 1-hour limit
110
+ (`verification_start_per_ip`, `password_reset_start_per_ip`, `signup_per_ip`,
111
+ `api_passwordless_start_per_ip`, `dynamic_registration_per_ip`). Each
112
+ `rate_limit` declaration's `within:` is now threaded to the handler (captured
113
+ in the per-limit `with:` closure the instant that limit trips, since
114
+ `ActionController::TooManyRequests` carries no window and a controller may
115
+ declare several limits with different windows), so `Retry-After` matches the
116
+ window that actually fired. A hand-rolled `raise` that bypasses the macro
117
+ falls back to 15 minutes.
118
+ - **Blank per-target rate-limit keys no longer collapse into one shared bucket.**
119
+ Five per-target limiters interpolated a param that can be blank, yielding a
120
+ stable key like `"reset-password:"` — a single global bucket (Rails'
121
+ `.compact` does not drop a non-nil empty string), so blank-target spam from
122
+ one source throttled every legitimate user. Affected: web login (by email),
123
+ API passwordless start (by target), email/phone verification start (by
124
+ target), and password-reset start (by email). Each now falls the key back to
125
+ the remote IP when the target is blank, keeping blank spam bounded per-IP
126
+ without poisoning real targets' buckets — mirroring the existing per-audience
127
+ token limiter's "only count well-formed values" shape.
128
+
129
+ ### Added
130
+
131
+ - **Mechanism-agnostic login rate-limit config: `rate_limits.login_per_ip`
132
+ (default 20) and `rate_limits.login_per_email` (default 5).** The login
133
+ `#create` action branches password OR passwordless, so on a passwordless app
134
+ the existing `password_login_per_*` fields actually govern the OTP-send limit
135
+ — a misnomer that led four consumer apps to write false config comments. The
136
+ new names describe the action, not a mechanism. When unset they inherit the
137
+ deprecated `password_login_*` values, so existing behaviour is unchanged; when
138
+ set they win.
139
+
140
+ ### Deprecated
141
+
142
+ - **`rate_limits.password_login_per_ip` / `rate_limits.password_login_per_email`
143
+ are deprecated in favour of `login_per_ip` / `login_per_email`.** They remain
144
+ fully honoured (the config schema rejects unknown fields at boot, so a host
145
+ still setting the old names keeps working); the login controller reads the new
146
+ alias and falls back to the deprecated field when the alias is left at its
147
+ default. Follows the `max_attempts` → `max_attempts_per_challenge` alias
148
+ precedent.
149
+
150
+ ## [0.28.0] - 2026-07-12
151
+
152
+ ### Added
153
+
154
+ - **Rate limiting on the last unprotected auth surfaces.** The web
155
+ password-reset request (`reset_password/start`), password signup, and the
156
+ email/phone code-*confirmation* endpoints now carry Rails-native
157
+ `rate_limit`s, closing email-flooding, account-enumeration,
158
+ account-creation-spam, and distributed code-guessing gaps. New config keys:
159
+ `rate_limits.password_reset_start_per_ip` (10/hr),
160
+ `rate_limits.password_reset_start_per_target` (3/15min), and
161
+ `rate_limits.signup_per_ip` (10/hr); the confirm endpoints reuse
162
+ `otp_verify_per_ip`. All use the existing `RateLimitStore` + centralized 429
163
+ handler.
164
+ - **The `passwordless.retry_delay` OTP-resend cooldown is now enforced.**
165
+ Previously the setting existed but did nothing. A resend for the same target
166
+ within the window (default 30s) is rejected with an `InvalidRequestError`;
167
+ the already-issued code stays valid. Set `retry_delay = 0` to disable.
168
+
169
+ ## [0.27.0] - 2026-07-03
170
+
171
+ ### Added
172
+
173
+ - **Loopback redirect URIs match on any port for public PKCE clients (RFC 8252
174
+ §7.3).** Native apps receive the authorization response on an ephemeral
175
+ local listener whose port cannot be known at registration time. When a
176
+ public client with `require_pkce` presents a redirect URI whose scheme is
177
+ `http` and whose host is a loopback literal (`127.0.0.1`, `::1`, or
178
+ `localhost`), and a registered redirect URI is likewise a loopback URI, the
179
+ comparison now ignores the port — host and path must still match exactly, so
180
+ `127.0.0.1` and `localhost` do not cross-match (RFC 8252 §8.3 recommends the
181
+ IP literals over `localhost`). Confidential clients and non-loopback URIs
182
+ keep strict scheme+host+port+path matching. Registration-time validation is
183
+ unchanged. Token exchange is unaffected: the redirect URI presented at the
184
+ token endpoint is still compared byte-for-byte against the value stored when
185
+ the code was issued.
186
+
187
+ ## [0.26.4] - 2026-06-22
188
+
189
+ ### Fixed
190
+
191
+ - **WebEngine controller redirects no longer drop the mount prefix on non-root
192
+ mounts.** Isolated-engine `_path` helpers are mount-relative, and
193
+ `redirect_to` / `redirect_with_inertia` (unlike `form_with` / `url_for` view
194
+ URL generation) do not prepend the mount's `SCRIPT_NAME`. So when the engine
195
+ was mounted at a non-root path (e.g. `mount StandardId::WebEngine => "/auth"`),
196
+ redirects produced prefix-less paths that 404'd — most visibly `POST
197
+ /auth/login` redirecting to `/login_verify` instead of `/auth/login_verify`,
198
+ breaking passwordless login. Form actions were never affected. All affected
199
+ redirects (`login` → `login_verify`; `login_verify` / reset-password →
200
+ `login`; session revoke → `sessions`; account update → `account`; and the
201
+ `after_sign_in` denial bounce) now prepend `request.script_name` via a new
202
+ `engine_path` helper (a no-op for root mounts). Regression test added to
203
+ `spec/integration/multi_mount_spec.rb`.
204
+
205
+ ## [0.26.3] - 2026-06-15
206
+
207
+ ### Fixed
208
+
209
+ - **Unauthenticated access to a protected web page no longer 500s.** The web
210
+ authentication guard (`require_browser_session!`) raises
211
+ `NotAuthenticatedError` / `InvalidSessionError` (for missing / expired /
212
+ revoked sessions) rather than redirecting. The API base controller rescued
213
+ these, but the web base controller did not — so an unauthenticated request to
214
+ a protected web page (e.g. `/sessions`) surfaced as a 500 instead of bouncing
215
+ to login. The web base controller now rescues both and redirects to the login
216
+ page, preserving the original destination.
217
+
218
+ ## [0.26.2] - 2026-06-15
219
+
220
+ ### Security
221
+
222
+ - **OTP codes and password-reset tokens no longer leak into the logs.** The
223
+ built-in mailers (`PasswordlessMailer`, `PasswordResetMailer`) pass the OTP
224
+ code / reset URL as mailer params, which `deliver_later` serializes as the
225
+ delivery job's arguments — and ActiveJob's log subscriber prints job arguments
226
+ in plaintext on enqueue/perform (e.g. `params: {email:…, otp_code: "03158369"}`).
227
+ StandardId's mailers now deliver via `StandardId::SecureMailDeliveryJob`
228
+ (`ActionMailer::MailDeliveryJob` with `log_arguments = false`), so the
229
+ arguments are kept out of the log stream. Delivery behaviour is unchanged.
230
+
231
+ ## [0.26.1] - 2026-06-15
232
+
233
+ ### Fixed
234
+
235
+ - **Passwordless `login_verify` OTP input now respects
236
+ `config.passwordless.code_length`.** The built-in ERB verification-code field
237
+ hardcoded `maxlength: 6`, so apps configuring a longer code (e.g.
238
+ `code_length = 8`) rendered a field that truncated input to 6 characters —
239
+ users could not enter the full code. The input now derives `maxlength` from
240
+ `StandardId::Passwordless.otp_code_length` (the same clamped 4..10 value the
241
+ OTP generator uses), exposed to views via a new
242
+ `StandardId::ApplicationHelper#otp_code_length` helper, so the form and the
243
+ generated code stay in sync end-to-end.
244
+ - **WebEngine rate-limit responses no longer 500 on hosts without a root
245
+ route.** When a web auth action (login, login_verify, email/phone verification
246
+ start) hit its rate limit, the handler redirected to
247
+ `request.referer || main_app.root_path`. That raised — and returned a 500
248
+ error page instead of the intended graceful response — for any host app that
249
+ doesn't define a `root` route (e.g. an API/control-plane that only mounts the
250
+ engine), and also for cross-origin `Referer` headers (Rails' open-redirect
251
+ guard). The handler now redirects back to the rate-limited form's own path
252
+ (`request.path`), which is always a valid same-origin GET. The ApiEngine
253
+ responses (JSON `429`) are unchanged.
254
+ - **OAuth/OIDC metadata no longer advertises a `jwks_uri` under symmetric
255
+ signing.** With the default HS256 (and HS384/HS512) there are no public keys
256
+ to publish, so the JWKS endpoint deliberately returns 404 — but the
257
+ authorization-server and openid-configuration documents advertised `jwks_uri`
258
+ unconditionally, pointing clients at a dead URL. `jwks_uri` is now emitted only
259
+ when signing is asymmetric (RS*/ES*). RFC 8414 makes it optional; HS-signed
260
+ tokens are verified with the shared secret, not JWKS.
261
+ - **Sign-out / unauthenticated requests no longer leave a non-HttpOnly
262
+ `session_token` cookie.** `clear_session!` assigned
263
+ `cookies.encrypted[:session_token] = nil`, which wrote a fresh encrypted blob
264
+ through the cookie jar's default options (no `HttpOnly`) on every
265
+ unauthenticated request. It now uses `cookies.delete(:session_token)` to remove
266
+ the cookie cleanly. The token-bearing sign-in write was already `httponly: true`
267
+ and is unchanged, so this is a hygiene/consistency fix, not a token exposure.
268
+
269
+ ## [0.26.0] - 2026-06-15
270
+
271
+ ### Added
272
+
273
+ - **`config.passwordless.production_env_detector`** — an optional callable that
274
+ decides whether the current deploy counts as "production" for the bypass-code
275
+ guard. When `nil` (default) the gem falls back to `Rails.env.production?`, so
276
+ existing consumers are unchanged. Apps that distinguish a physical deploy
277
+ environment from `RAILS_ENV` (e.g. a staging box still running
278
+ `RAILS_ENV=production`) can supply `-> { AppEnv.production? }` to permit a
279
+ `bypass_code` on staging while it stays refused on real production. The guard
280
+ in `Passwordless::VerificationService` (which also backs `Otp.verify`) now
281
+ defers to this detector instead of checking `Rails.env.production?` directly.
282
+
283
+ ## [0.25.0] - 2026-06-13
284
+
285
+ ### Changed
286
+
287
+ - **Browser session cookie now persists across browser restarts.** The
288
+ encrypted `session_token` cookie is written with an explicit `expires` tied
289
+ to the `BrowserSession#expires_at` (previously a bare session cookie that was
290
+ cleared on full browser close, logging users out well before their session
291
+ actually expired). The cookie is also hardened with `httponly: true`,
292
+ `same_site: :lax`, and `secure` following `request.ssl?`. Combined with a
293
+ host-configured `session.browser_session_lifetime`, this lets a "remember me
294
+ for N days" session survive closing and reopening the browser. Applies to
295
+ both the sign-in and remember-token re-auth paths.
296
+
297
+ ## [0.24.0] - 2026-06-13
298
+
299
+ ### Added
300
+
301
+ - **Public-client (PKCE) support at `POST /oauth/token` for the
302
+ `authorization_code` grant.** Public clients (native/SPA/MCP clients per
303
+ RFC 8252 / OAuth 2.1) can now exchange an authorization code for tokens
304
+ using PKCE alone, with no `client_secret`. Confidential clients still
305
+ authenticate with a secret exactly as before (regression-safe). The flow
306
+ looks up the `ClientApplication` by `client_id`, validates a secret only
307
+ for confidential clients, rejects a public client that sends a
308
+ `client_secret` (`invalid_client`), and **fails closed** when a public
309
+ client's authorization code carries no `code_challenge` — PKCE is the
310
+ client's only authentication factor, so a code minted without one is
311
+ rejected with `invalid_grant`. `"none"` is now advertised in
312
+ `token_endpoint_auth_methods_supported` in both discovery documents.
313
+ - **`oauth.dynamic_registration_default_auth_method` config** (default
314
+ `"none"`). Controls the `token_endpoint_auth_method` applied to clients
315
+ created via RFC 7591 Dynamic Client Registration when the request omits
316
+ one — i.e. whether self-registered clients default to public (PKCE-only)
317
+ or confidential (secret-bearing). Validated at use against
318
+ `none` / `client_secret_basic` / `client_secret_post`; an out-of-range
319
+ value raises `ConfigurationError`. Default preserves existing behaviour.
320
+
321
+ ### Fixed
322
+
323
+ - **Consent screen now completes for Inertia-rendered hosts.** When a host
324
+ renders the OAuth consent screen via Inertia (`use_inertia`), the
325
+ approve/deny decision arrives as an Inertia XHR, which cannot follow a 302
326
+ to the external client `redirect_uri` — the browser would hang on the
327
+ consent screen. `ConsentController` now emits an Inertia-Location
328
+ (`409` + `X-Inertia-Location`) for Inertia requests so the client performs a
329
+ hard navigation to the callback, while plain (ERB) form posts keep the
330
+ ordinary redirect. No effect on non-Inertia hosts.
331
+
332
+ ## [0.23.0] - 2026-06-12
333
+
334
+ ### Added
335
+
336
+ - **Per-audience rate limits at `POST /oauth/token`** — new
337
+ `rate_limits.api_token_per_audience_per_ip` config (Hash of
338
+ audience => max requests per IP per 15 minutes, default `{}`). Lets hosts
339
+ tighten the cap for higher-risk audiences (e.g. a public mobile app) while
340
+ internal/partner audiences keep the global `api_token_per_ip` ceiling. A
341
+ request must pass both its audience cap and the global cap. Implemented as
342
+ an explicit `before_action` counter rather than the Rails `rate_limit`
343
+ DSL: the DSL counts every request reaching the action, and a `by:` block
344
+ returning `nil` does not exempt a request — it collapses into a shared
345
+ bucket keyed without the discriminator, so one audience's rule would
346
+ throttle every other audience's traffic. Only requests that actually
347
+ target a configured audience increment that audience's per-IP counter.
348
+ Exceeding the cap renders the standard `rate_limit_exceeded` JSON error
349
+ with `Retry-After`.
350
+
351
+ ### Fixed
352
+
353
+ - **`SOCIAL_AUTH_FAILED` is now emitted on the API (mobile) callback path
354
+ too.** Since 0.16.0 the event fired only from the web callback
355
+ (`Web::Auth::Callback::ProvidersController`); on
356
+ `POST /api/oauth/callback/:provider` an infrastructure-level provider
357
+ failure (`StandardId::OAuthError` from the provider call) fell through to
358
+ the standard `handle_oauth_error` JSON response without emitting, so host
359
+ apps observing provider outages via the event were blind on the API flow
360
+ (and had to monkey-patch `get_user_info_from_provider` to compensate).
361
+ The rescue is scoped to the provider call: `OAuthError` subclasses raised
362
+ later in the flow (`SocialLinkError`, `InvalidRequestError`, ...) are
363
+ policy/client errors and still do not emit. The JSON error response is
364
+ unchanged.
365
+
366
+ ## [0.22.0] - 2026-06-11
367
+
368
+ ### Added
369
+
370
+ - **RFC 7591 Dynamic Client Registration** behind a default-off toggle. New
371
+ endpoint `POST /oauth/register`
372
+ (`Api::Oauth::RegistrationsController` -> `StandardId::Oauth::ClientRegistration`)
373
+ lets clients self-register OAuth client applications.
374
+ - **Rate limited.** Throttled by IP via
375
+ `rate_limits.dynamic_registration_per_ip` (default 10/hour) so an enabled
376
+ deployment can't be flooded with client rows.
377
+ - **Default off.** Gated on `oauth.dynamic_registration_enabled` (default
378
+ `false`). While off, the endpoint returns **404** (fully absent, not just a
379
+ guarded 403) and the discovery documents do **not** advertise a
380
+ `registration_endpoint`. An open, unauthenticated registration endpoint is
381
+ state-mutating attack surface, so it is strictly opt-in.
382
+ - **Owner resolver.** When enabled, set
383
+ `oauth.dynamic_registration_owner` to a callable resolving the polymorphic
384
+ owner for registered clients (e.g. `-> { Organization.default }`). If the
385
+ toggle is on but the resolver is nil/returns nil, registration raises a
386
+ clear `StandardId::ConfigurationError` rather than silently failing model
387
+ validation.
388
+ - **Metadata -> ClientApplication mapping** (RFC 7591 §2): `redirect_uris`
389
+ (REQUIRED — empty/invalid yields `invalid_redirect_uri`), `client_name` ->
390
+ `name` (a name is generated when absent), `scope` (default
391
+ `"openid profile email"`). `grant_types` is whitelisted to
392
+ `authorization_code`/`refresh_token` and `response_types` to `code` (others
393
+ rejected as `invalid_client_metadata`). `token_endpoint_auth_method` `none`
394
+ -> **public** client; `client_secret_basic`/`client_secret_post` ->
395
+ **confidential** (a one-time `client_secret` is generated and returned with
396
+ `client_secret_expires_at: 0`). Default auth method is `none` (public).
397
+ - **Forced security defaults.** All registered clients are forced onto
398
+ `require_pkce: true` + `code_challenge_methods: "S256"` (the model also
399
+ validates this for public clients). Registered clients default to
400
+ `require_consent: true` — they get the HTML consent screen shipped in
401
+ 0.21.0 rather than the old `require_consent: false` workaround.
402
+ - **Discovery advertisement.** Both `/.well-known/openid-configuration` and
403
+ `/.well-known/oauth-authorization-server` advertise
404
+ `registration_endpoint` **only when** `oauth.dynamic_registration_enabled`
405
+ is true (the flag is now read from config and passed into
406
+ `DiscoveryDocument.build`).
407
+ - Responses follow RFC 7591 §3.2.1 (HTTP 201) on success and §3.2.2 (HTTP
408
+ 400, `invalid_redirect_uri` / `invalid_client_metadata`) on error. No
409
+ migration required — all `ClientApplication` columns already exist.
410
+
411
+ ## [0.21.1] - 2026-06-11
412
+
413
+ ### Fixed
414
+
415
+ - **ERB login view now respects the `web.signup` and `web.password_reset`
416
+ toggles.** The packaged login view rendered an unconditional "Sign up" link
417
+ (both the passwordless and password branches) and a "Forgot password?" link
418
+ (password branch), so an app with `web.signup = false` or
419
+ `web.password_reset = false` showed links to routes that 404. The links are
420
+ now gated on their respective toggles. No effect on apps that leave the
421
+ toggles at their defaults.
422
+
423
+ ## [0.21.0] - 2026-06-11
424
+
425
+ ### Added
426
+
427
+ - **RFC 8414 OAuth 2.0 Authorization Server Metadata** — new endpoint
428
+ `/.well-known/oauth-authorization-server` (`Api::WellKnown::OauthAuthorizationServerController`),
429
+ serving the same document as `/.well-known/openid-configuration`. Both
430
+ controllers now render a single shared builder,
431
+ `StandardId::Oauth::DiscoveryDocument.build(issuer, registration_enabled: false)`,
432
+ so the OIDC and OAuth metadata documents cannot drift.
433
+ - **Mount caveat:** the ApiEngine is consumer-mounted at a sub-path (e.g.
434
+ `/auth/api`), so the gem can only serve this at
435
+ `/auth/api/.well-known/oauth-authorization-server`. A strict RFC 8414 client
436
+ that derives a *root-anchored* URL from a path-carrying issuer
437
+ (`<host>/.well-known/oauth-authorization-server/auth/api`) lands outside any
438
+ engine mount; hosts needing the root-anchored form must add their own root
439
+ route — the gem cannot. The `registration_endpoint` is intentionally NOT
440
+ emitted yet; the `registration_enabled:` kwarg is a seam for Phase 2 (DCR).
441
+ - **PKCE advertisement** — both discovery documents now advertise
442
+ `code_challenge_methods_supported: ["S256"]` (always on; PKCE is always
443
+ enforced).
444
+ - **HTML consent view for the authorization-code flow** — an authenticated,
445
+ interactive (HTML) `/authorize` for a client with `require_consent` enabled
446
+ and no prior grant is now handed off to a new WebEngine consent screen
447
+ (`GET/POST /consent`, asset-free ERB; Inertia consumers receive props for
448
+ their own component) instead of dead-ending. On approve, a `ClientGrant` is
449
+ recorded and the authorization code is issued by re-running the same
450
+ authorization-code flow (so `redirect_uri` and PKCE are revalidated, not
451
+ duplicated); on deny, the user is redirected back with `error=access_denied`
452
+ (+ `state`). Repeat authorizations with a matching grant skip consent. The
453
+ API authorize endpoint carries the original `/authorize` params to the
454
+ consent screen through a signed, expiring payload
455
+ (`StandardId::Oauth::ConsentPayload`, mirroring the OTP `message_verifier`
456
+ pattern). New table `standard_id_client_grants` (one row per account+client).
457
+ JSON / non-interactive / implicit / social-login flows are unaffected.
458
+
459
+ ### Changed
460
+
461
+ - **`audience` is now OPTIONAL at the authorization-code `/authorize`** — moved
462
+ from `expect_params` to `permit_params` in
463
+ `AuthorizationCodeAuthorizationFlow`. Token-time validation already no-ops on a
464
+ blank audience (or when no `allowed_audiences` are configured), so omitting it
465
+ is safe and lets standards-compliant clients (e.g. MCP) authorize without it.
466
+ `client_credentials` still REQUIRES `audience` (unchanged). This is a
467
+ relaxation, not a break.
468
+ - **Passwordless-aware ERB login view** — the gem's ERB login view now selects
469
+ its form using the same passwordless-first precedence the controller's
470
+ `#create` uses: passwordless-only renders an asset-free email-only form (no
471
+ external `tailwindcss.com` logo, no Tailwind-utility dependence, so it renders
472
+ under a minimal element-CSS layout); password mode renders the existing form
473
+ unchanged (password consumers are unaffected); neither-enabled renders a "No
474
+ login method is enabled" message instead of a 500. Social login still renders
475
+ in both modes when configured.
476
+
477
+ ### Migration notes
478
+
479
+ - Run the new `CreateStandardIdClientGrants` migration (adds
480
+ `standard_id_client_grants`). No existing columns change.
481
+
482
+ ## [0.20.1] - 2026-05-24
483
+
484
+ ### Added
485
+
486
+ - **`after_sign_in` hook context now includes `:redirect_uri`** — the caller-supplied destination (from the form param for password/signup flows, from the OAuth state cookie for social flows, from `session[:return_to_after_authenticating]` for passwordless OTP). Host hooks that always return a default path (e.g. `PostLoginRedirect.new(account).path`) silently shadowed the caller's `redirect_uri` because `redirect_override` wins in the destination chain. Hooks can now return `nil` when `context[:redirect_uri]` is present so the originator's URL is honoured — required for OAuth/SSO flows where the host bounces through `/login?redirect_uri=/oauth/authorize?…` and expects the handshake to complete back to the originating consumer (e.g. external API clients hitting `/api/v1/authorize`).
487
+ - **All four sign-in flows now forward the caller's redirect_uri into the hook context**: password login, signup, social callback, AND passwordless OTP verify (`web/login_verify_controller.rb`). Previously only the first three were covered; passwordless users initiating OAuth from a consumer landed on the host default page.
488
+
489
+ ### Fixed
490
+
491
+ - **Cancel-at-provider preserves `redirect_uri`** — `handle_callback_error` (provider returns `?error=access_denied`) now extracts the state and forwards `redirect_uri` to `login_path`, symmetric with the `SocialLinkError`/`OAuthError` rescue paths. Previously a user who cancelled at the provider lost the OAuth handshake context entirely.
492
+ - **Open-redirect / 500 mitigation in social callback** — when the host hook defers (returns nil), the social callback validates the originator-supplied destination via `safe_destination?` (same-origin paths or `allowed_redirect_url_prefixes` matches only; rejects protocol-relative and arbitrary cross-host URLs). On failure, falls back to `/` instead of feeding the unsafe value into `redirect_to`. Closes a class of phishing vectors that opened when host hooks started deferring instead of always returning an internal path.
493
+ - **`params[:redirect_uri]` Array/Hash type safety** — login, signup, login_verify, and logout controllers now use a `string_param` helper that returns nil for non-String shapes (e.g. `redirect_uri[]=a&redirect_uri[]=b`), preventing a self-DoS 500 from `redirect_to <Array>`. Covers all `params[:redirect_uri]` read sites: form re-renders (`show`/error branches), session writes (`handle_passwordless_login`), state encoding (`signup_controller#encode_state`), and direct redirects (`logout`). Also normalizes empty-string values via `.presence` consistently across the context and destination chain.
494
+ - **Open-redirect validation extended to password, signup, logout, AND passwordless verify** — `safe_destination?` and `safe_post_signin_default` are now promoted to `Web::BaseController` and applied to the destination chain for password login, password signup, logout, and passwordless OTP verify (was previously social callback only). `safe_destination?` accepts same-origin absolute URLs (compares against `request.base_url`) so legitimate `store_location_for_redirect` round-trips still work. Cross-host URLs not in `allowed_redirect_url_prefixes`, protocol-relative URLs (`//evil.com/`), and same-origin-looking-but-cross-host URLs (e.g. `http://evil.com:80/`) fall back to `after_authentication_url` / `safe_post_signin_default` instead of redirecting to an attacker-controlled target. Closes a residual open-redirect in passwordless verify where a malicious String redirect_uri passed `string_param` (which only blocks Array/Hash) and got stashed in `session[:return_to_after_authenticating]`, then served unfiltered.
495
+ - **Scope-level `after_sign_in_path` no longer shadows caller's redirect_uri** — when the host hook returns nil AND `context[:redirect_uri]` is present, `LifecycleHooks#invoke_after_sign_in` now returns nil (the documented "defer to originator" signal) instead of `scope_config&.after_sign_in_path`. Hosts that configure both a scope path AND OAuth/SSO flows previously had the scope path silently win and break the handshake.
496
+ - **Defensive nil-guard on `state_data['redirect_uri']`** in the social callback — uses `state_data&.dig("redirect_uri").presence` consistent with the rescue paths.
497
+
498
+ ## [0.20.0] - 2026-05-21
499
+
500
+ ### Changed (BREAKING — behavior)
501
+
502
+ - **OAuth token grants now fail closed when the requested audience has a configured profile binding but the account has no matching active profile.** Previously, `TokenGrantFlow` only validated `aud ∈ allowed_audiences`; if `c.oauth.audience_profile_types[aud]` was set but the account lacked a matching profile, the mint silently succeeded with profile-derived claims (e.g. `gid`) resolving to `nil`. The new behavior raises `StandardId::NoBoundProfileError` (a subclass of `InvalidGrantError`), which the standard OAuth error handler renders as RFC 6749 `invalid_grant` (HTTP 400). Decode-time enforcement via `AudienceVerification` is unchanged.
503
+ - **`AudienceProfileResolver` now exposes a strict `.resolve!(account:, audience:)` method** used by `TokenGrantFlow`. It returns the uniquely matching active profile, or raises `NoBoundProfileError` (no match) / `AmbiguousProfileError` (multiple matches). The legacy `.call(account:, audience:)` is unchanged — it still returns the "first active else first match" profile and is used by the decode-time `AudienceVerification` concern, where back-compat tolerance is intentional.
504
+
505
+ ### Added
506
+
507
+ - `StandardId::NoBoundProfileError` and `StandardId::AmbiguousProfileError` — both subclass `InvalidGrantError` so existing OAuth error handlers map them to `invalid_grant`. Exposed readers (`audience`, `expected_profile_types`, `profile_ids`) are for audit logging only; do **not** interpolate them into client-facing responses.
508
+ - **`OAUTH_TOKEN_ISSUED` event payload now includes** `profile_id`, `audience`, `jti`, and `requested_scopes` (in addition to the existing `grant_type`, `client_id`, `account`, `expires_in`). Without these, downstream subscribers (SIEM, audit log, anomaly detection) could not correlate a successful mint to the entity it authorized, the resource server it targeted, the specific token for revocation, or the scopes the client requested. Existing subscribers are unaffected — payload additions are backward-compatible.
509
+ - `claim_resolvers_context` now exposes the pre-resolved `profile` (when a binding matched), so host-app claim resolvers can use it directly via keyword filtering instead of re-querying.
510
+
511
+ ### Migration notes
512
+
513
+ Host apps with **multiple active profiles of the same type for a single account** will see previously-silent mints now fail with `AmbiguousProfileError`. Two options:
514
+
515
+ 1. **Recommended:** Treat duplicates as a data-integrity bug and deactivate the superfluous profiles. The previous "pick the first arbitrary active match" behavior was non-deterministic across reloads and unsafe to rely on.
516
+ 2. **Temporary:** Configure a custom `c.oauth.audience_profile_resolver` callable that applies your own selection rule. The strict path delegates to it when set.
517
+
518
+ An explicit per-grant `profile_id` parameter is intentionally out of scope for this release; the grant-parameter contract for profile selection will be designed separately once host apps have migrated off duplicate profiles.
519
+
520
+ ## [0.19.0] - 2026-05-19
521
+
522
+ ### Added
523
+
524
+ - **`Api::Oauth::Callback::ProvidersController` now forwards non-OAuth request params** to `SOCIAL_AUTH_COMPLETED` subscribers as `original_request_params`. Previously the API (mobile) flow always passed an empty hash, blocking host-app attribution tracking for mobile signups. Reserved OAuth/Rails keys (`id_token`, `code`, `scope`, `scopes`, `audience`, `redirect_uri`, `flow`, `state`, `nonce`, `provider`, `controller`, `action`, `format`, `authenticity_token`, `utf8`, `_method`) are stripped; everything else is treated as opaque host-supplied data and forwarded through. Mirrors the web flow's existing `state_data` pass-through contract.
525
+
526
+ ## [0.18.0] - 2026-05-19
527
+
528
+ ### Changed
529
+
530
+ - Relaxed `jwt` dependency constraint from `~> 2.7` to `>= 2.7, < 4`, allowing consumers to satisfy the GHSA security advisory for `jwt` 2.x by upgrading to `jwt` 3.x. Existing 2.x users are unaffected. Consuming apps that bump to `jwt` 3.x should verify their own JWT encode/decode call sites against the [jwt 3.0 release notes](https://github.com/jwt/ruby-jwt/blob/main/CHANGELOG.md) — `JWT.encode` / `JWT.decode` calls inside `StandardId::JwtService` already pass an explicit algorithm and are 3.x-compatible.
531
+
532
+ ## [0.17.1] - 2026-05-07
533
+
534
+ ### Fixed
535
+
536
+ - **`Otp.issue(delivery: :manual)` no longer double-delivers when `c.passwordless.delivery == :built_in`.** Previously, `BaseStrategy#start!` emitted `PASSWORDLESS_CODE_GENERATED` unconditionally and `PasswordlessDeliverySubscriber` gated only on the global delivery config — so callers who passed `delivery: :manual` and delivered the code themselves (custom widget/verification/step-up flows) silently received a duplicate email from the bundled mailer on top of their own. `skip_sender` is now forwarded into the event payload, and the subscriber short-circuits when it sees the flag. Manual callers get exactly one delivery again, in line with the documented contract for `:manual`. (#206)
537
+
538
+ ## [0.17.0] - 2026-04-29
539
+
540
+ ### Changed
541
+
542
+ - Release workflow migrated to the shared `rarebit-one/.github` reusable workflow (`reusable-gem-release.yml@v1`); `.github/workflows/release.yml` is now a thin shim. CI workflow remains bespoke pending unrelated open PRs that touch it.
543
+ - **Widened Rails constraint to `>= 8.0`** — gemspec now allows Rails 9+ when available. Aligns with the org-wide policy of supporting Rails 8 and up with no upper bound.
544
+ - Replaced the vendored `StandardConfig` schema/manager (~430 LOC across `lib/standard_config/`) with `ActiveSupport::OrderedOptions` plus a small internal `StandardId::ConfigSchema` helper (~200 LOC). No public API change for consumers using `StandardId.configure { |c| ... }` or `StandardId.config.foo`. The top-level `StandardConfig` constant has been removed — it was internal-only and shipped under standard_id's lib path, but its name implied a separate gem and risked namespace collisions.
545
+
546
+ ### Added
547
+
548
+ - **Rails edge CI canary** — a non-blocking `test (rails-edge)` job runs the spec suite against `rails/rails@main` on every PR. Failures surface upstream breakage during development rather than at a host app's `bundle update` after a Rails 9 release. Allowed to fail (`continue-on-error: true`) so it never blocks merges.
549
+
550
+ ### Fixed
551
+
552
+ - **Weekly maintenance concurrency guard** — added a `concurrency:` block to `weekly-maintenance.yml` so a manual `workflow_dispatch` during an in-flight scheduled run no longer spawns a parallel job. `cancel-in-progress: false` lets the running job finish rather than orphan a half-open PR. Follow-up to #199.
553
+
554
+ ### Removed
555
+
556
+ - **BREAKING:** Dropped support for Ruby < 4.0. `required_ruby_version` is now `>= 4.0`. Hosts must upgrade to Ruby 4.0+ before bundling this version. CI tests all four published 4.0.x patches.
557
+
558
+ ## [0.16.1] - 2026-04-19
559
+
560
+ ### Performance
561
+
562
+ - **API authentication guard reuses `session_manager.current_account`** — `Api::AuthenticationGuard` previously ran its own `find_by(id: api_session.account_id)` twice per bearer-authenticated request (once each for `SESSION_VALIDATED` and `SESSION_EXPIRED`), on top of the session_manager's already-memoized `current_account`. The guard now threads `session_manager` through to the event emitters and delegates account resolution to it. Eliminates 1-2 redundant queries per API request. (#188)
563
+ - **`RefreshToken#revoke_family!` uses a recursive CTE** — family chain traversal was a Ruby loop doing `.pluck(:id)` per generation (O(depth) queries). Now a single recursive CTE collects every ancestor and descendant in one round trip. `UNION` (not `UNION ALL`) deduplicates against the full accumulator to prevent infinite loops on cyclic data. Supported by PostgreSQL, SQLite 3.8+, and MySQL 8+. (#188)
564
+ - **`Api::SessionsController#serialize_session` drops redundant `respond_to?` guards** — all `Session` subclasses share the STI table, so per-field `respond_to?` checks were defensive overhead with no missing method to defend against. Direct column access is both cheaper and clearer. (#188)
565
+
566
+ ### Added
567
+
568
+ - **`config.session.token_digest_cost`** — opt-in BCrypt cost factor for session `token_digest`. Default `nil` preserves current behavior (bcrypt-ruby's built-in default — cost 12 in production). Since session tokens are 256-bit random (`SecureRandom.urlsafe_base64(32)`), any cost `>= 10` is well beyond brute-force, and setting `10` saves ~200ms of CPU per session creation. Clamped to `BCrypt::Engine::MIN_COST..MAX_COST`. (#188)
569
+ - **Current request details mirrored into `Rails.event` context** — host apps observing structured logs/events see the same `request_id`, `remote_ip`, and `user_agent` values that StandardId records on sessions, without needing to duplicate the wiring. (#187)
570
+
571
+ ## [0.16.0] - 2026-04-19
572
+
573
+ ### Security
574
+
575
+ - **OTP verification race-condition fix and per-challenge brute-force defenses** — `VerificationService.verify` now wraps the challenge lookup, failed-attempt increment, and consumption in a single `SELECT ... FOR UPDATE` transaction, closing the TOCTOU window between "find active challenge" and "mark it used." Failed-attempt counting is now atomic and scoped to the specific challenge (previously a loose read-modify-write on the account). Events are deferred to post-commit so observers never see rolled-back state. New `config.passwordless.max_attempts_per_challenge` (default `5`) supersedes the now-deprecated account-wide `max_attempts` (kept as a fallback for existing installs). (#169)
576
+ - **JWT audience enforcement at decode time** — `JwtService.decode` now accepts an `allowed_audiences:` kwarg and raises `StandardId::InvalidAudienceError` on mismatch. `Api::TokenManager#verify_jwt_token` threads `config.oauth.allowed_audiences` through automatically, so cross-audience JWT replay is now blocked even on controllers that forget to include the `AudienceVerification` concern. Production emits a warning when `allowed_audiences` is unset. (#170, #174)
577
+ - **Web flow polish** — password-reset delivery moved to an async job with a constant-time success response (closes enumeration timing leak); OAuth `redirect_uri` validation tightened to exact scheme+host+port+path match at both registration and authorize time (blocks query-string piggyback); engine logs a warning when the host app has no `secret_key_base` configured so encrypted session cookies can't silently fall back to plaintext. New `reset_password` config scope with `:delivery` (`:custom` default, `:built_in` opt-in) and mailer-sender/subject knobs. `CREDENTIAL_PASSWORD_RESET_INITIATED` event now fires from the job. (#171)
578
+ - **Per-client PKCE enforcement at the authorize endpoint** — honors the existing `require_pkce` column on `ClientApplication`. Requests missing `code_challenge` are rejected with `invalid_request` when the client requires PKCE. Per-client `code_challenge_methods` replaces the global S256-only hardcode (case-insensitive). New validation blocks public clients from opting out (`public_clients_must_require_pkce`). (#175)
579
+ - **Hardened GitHub Actions workflows** — minimal `permissions:` blocks added to every workflow; third-party actions pinned to commit SHAs. (#185)
580
+
581
+ ### Added
582
+
583
+ - **Typed identifier accessors on `AccountAssociations`** — `account.email_identifier`, `account.phone_number_identifier`, `account.username_identifier` replace the manual `identifiers.detect { |i| i.type == "…" }` pattern used by consuming apps. Uses loaded-association detection to stay N+1-safe. (#180)
584
+ - **`SOCIAL_AUTH_FAILED` event** — emitted when social provider callbacks catch `StandardId::OAuthError` from an infrastructure failure (DNS, SSL, timeout). Policy/link errors (`SocialLinkError`) emit their existing `SOCIAL_LINK_BLOCKED` event instead. Enables host apps to observe provider outages without monkey-patching. (#180)
585
+ - **Idempotent event subscriptions** in `AccountStatus` / `AccountLocking` — guarded with a module-level flag so re-including a concern (e.g., Rails reload) no longer accumulates duplicate subscribers. (#180)
586
+ - **Errors module eager-loaded from all engines** — `StandardId::SocialLinkError` and the full error hierarchy are available at engine load time, so `rescue_from StandardId::SocialLinkError` at controller class-body time resolves as a constant instead of needing a string literal. (#180)
587
+ - **`bin/dev`** — dummy-app boot script for contributors; provisions the SQLite dev DB via `rake app:db:setup` if absent and execs `spec/dummy/Procfile.dev` through overmind/hivemind/foreman. (#173)
588
+ - **Boot-time config validators** — new `StandardId::Config::CallableValidator` and `StandardId::Config::ScopeClaimsValidator` raise `StandardId::ConfigurationError` at engine `after_initialize` if lifecycle callables have wrong arity or if `scope_claims` entries reference claims without a matching resolver. Surfaces typos at deploy time instead of at callback time. (#173)
589
+ - **Cleanup rake tasks** — `standard_id:cleanup:{sessions,refresh_tokens,authorization_codes,code_challenges,all}` honoring `GRACE_DAYS` env var, plus `docs/OPERATIONS.md` with scheduling examples for SolidQueue recurring, sidekiq-cron, whenever, and cron. (#173)
590
+
591
+ ### Performance
592
+
593
+ - **`first_sign_in?` uses `.exists?` instead of `.count`** on the `LifecycleHooks` hot path — removes a full count on every login. (#172)
594
+ - **Bulk session revocation uses `update_all`** in `Api::OAuth::RevocationsController` — one SQL UPDATE instead of O(N) per-row UPDATEs across sessions + cascaded refresh_tokens. `SESSION_REVOKED` event emission preserved per-session. (#172)
595
+ - **Partial indexes on hot active-row lookups** — `standard_id_sessions(expires_at) WHERE revoked_at IS NULL`, same for `refresh_tokens`, and `code_challenges(realm, channel, target, created_at) WHERE used_at IS NULL`. Dropped the unused Postgres GIN index on `code_challenges.metadata`. Migration uses `algorithm: :concurrently` with `disable_ddl_transaction!` on Postgres. (#172)
596
+ - **Isolate `SESSION_REVOKED` subscriber failures during bulk revoke** — a failing subscriber no longer aborts the revocation loop. (#172)
597
+
598
+ ### Deprecated
599
+
600
+ - **`config.passwordless.max_attempts`** — use `max_attempts_per_challenge` instead. The old key is still read as a fallback when the new one is unset, so existing installs keep working. Planned for removal in 2.0. (#169)
601
+
602
+ ### Changed
603
+
604
+ - **OTP code format now allows leading zeros** — `StandardId::Passwordless.generate_otp_code` (new consolidated generator, replacing the inline generators in `VerifyEmail::StartController`, `VerifyPhone::StartController`, and `BaseStrategy`) produces codes in the range `[0, 10**n)` zero-padded to the configured length, so values like `"000123"` are now valid. The previous generators produced integers in `[10**(n-1), 10**n)`, which never had leading zeros. Entropy is unchanged; host apps that stored or displayed codes as integers should treat them as strings. (#169)
605
+
606
+ ### Chore
607
+
608
+ - Deleted stale top-level `test_authorization_flows.rb` scaffolding. (#173)
609
+
610
+ ## [0.15.0] - 2026-04-18
611
+
612
+ ### Added
613
+
614
+ - **`StandardId::Otp` public primitive** — New realm-parameterized module (`Otp.issue` / `Otp.verify`) that wraps the hardened passwordless `VerificationService`. Enables OTP flows outside authentication (e.g. contact-verification widgets) without reimplementing enumeration defense, atomic failed-attempt tracking, or the `bypass_code` E2E hook. Supports `:built_in`, `:custom`, and `:manual` delivery modes. (#181)
615
+ - **`JwtService.sign` / `.verify` primitives** — Low-level JWT encode/decode that don't consult config, useful for HS256 service-to-service tokens and similar use cases. Existing `encode` / `decode` / `decode_session` methods unchanged — use those for OAuth flows. Typed error hierarchy under `StandardId::InvalidTokenError` (`ExpiredTokenError`, `InvalidSignatureError`, `InvalidAlgorithmError`, `InvalidAudienceTokenError`). (#177)
616
+ - **`session_type_resolver` callback** — New `config.session.session_type_resolver` decides whether web/API/OAuth sign-ins produce a `BrowserSession`, `DeviceSession`, or `ServiceSession`. Default mirrors current selection. OAuth token grants can now optionally persist a session row (opt-in via the resolver). (#182)
617
+ - **`audience_profile_types` map + audience-aware claim resolvers** — New `config.oauth.audience_profile_types` maps each audience to an allowed profile type (or array), enforced automatically in `AudienceVerification`. `claim_resolvers` now receive `audience:` in their context (via `CallableParameterFilter`), so resolvers can branch per audience. New `OAUTH_AUDIENCE_MISMATCH` event and `InvalidAudienceProfileError`. (#179)
618
+ - **Multi-profile-type scopes + per-scope `authorizer` + `scope_resolver` callback** — Scope config accepts `profile_types:` (plural array) in addition to legacy `profile_type:` singular (deprecated but still works). Each scope may declare an `authorizer:` callable for role-based / wildcard logic that runs after the profile-type check. New `config.scope_resolver` detaches scope resolution from the URL convention — apps using alternate URL schemes (e.g. `control_plane` param) no longer need to override `current_scope_config`. (#178)
619
+ - **Cleanup jobs for authorization codes + code challenges** — `CleanupExpiredAuthorizationCodesJob` and `CleanupExpiredCodeChallengesJob` with dual grace windows (7-day for expired, 1-day for consumed/used — replay-forensics only). Full `standard_id:cleanup:{sessions,refresh_tokens,authorization_codes,code_challenges,all}` rake task set. (#183)
620
+ - **Multi-step install generator** — `rails g standard_id:install` now writes the initializer with grouped sections, appends `mount StandardId::WebEngine` / `ApiEngine` to `config/routes.rb`, auto-runs `rake standard_id:install:migrations`, and prints a post-install checklist pointing at `AccountAssociations`, `WebAuthentication`/`ApiAuthentication`, and cleanup jobs. Flags: `--skip-initializer`, `--skip-routes`, `--skip-migrations`. Idempotent on re-run. (#176)
621
+
622
+ ### Deprecated
623
+
624
+ - **`ScopeConfig#profile_type` singular** — Use `profile_types:` (plural) instead. Singular still accepted, emits an `ActiveSupport::Deprecation` warning. Planned for removal in 2.0. (#178)
625
+
626
+ ## [0.14.4] - 2026-04-14
627
+
628
+ ### Fixed
629
+
630
+ - **Prevent OTP race condition with multiple active challenges** — When a user requests a new OTP before the previous one expires, multiple active challenges could accumulate. The verification lookup returned an arbitrary match, causing valid codes to be rejected. Now invalidates existing active challenges when creating a new one, with ordered lookup as a defensive fallback. Adds composite index on `code_challenges` for the new query pattern. (#165)
631
+
632
+ ### Changed
633
+
634
+ - Bump puma from 7.2.0 to 8.0.0 (#162)
635
+ - Group all Dependabot updates including majors (#163)
636
+
637
+ ## [0.14.3] - 2026-04-02
638
+
639
+ ### Fixed
640
+
641
+ - **Guard `apply_skips!` against unloaded `ControllerPolicy`** — When `skip_host_authorization` is called from a Rails initializer, `ControllerPolicy` may not be autoloaded yet by Zeitwerk, causing a `NameError`. The method now checks `defined?` before accessing the constant. Controllers that register later still receive skips via the `apply_to_controller` callback and the `to_prepare` re-run. (#160)
642
+
643
+ ## [0.14.2] - 2026-04-02
644
+
645
+ ### Fixed
646
+
647
+ - **Lowercase controller name in Inertia component name generation** — `inertia_component_name` produced PascalCase names like `"standard_id/Login/show"` because `.demodulize` preserves class casing. Adding `.underscore` produces `"standard_id/login/show"` which matches the lowercase page file conventions used by consuming apps. (#158)
648
+
649
+ ## [0.14.1] - 2026-03-28
650
+
651
+ ### Fixed
652
+
653
+ - **Preserve TLS SNI hostname in SSRF-protected connections** — The SSRF protection layer now preserves the original hostname for TLS Server Name Indication (SNI), preventing certificate verification failures when connecting through resolved IP addresses. (#154)
654
+
655
+ ## [0.14.0] - 2026-03-26
656
+
657
+ ### Added
658
+
659
+ - **Configurable `username_validator` for passwordless flows** — New `config.passwordless.username_validator` callable that runs before OTP generation to validate the recipient address (e.g. via truemail). Returns nil/false to proceed, or an error message string to reject with `InvalidRequestError`. Follows the same pattern as `account_factory` and `before_sign_in` hooks. (#150)
660
+ - **Integration tests for multi-mount WebEngine with scope defaults (RAR-93)** — Comprehensive integration tests verifying multiple WebEngine mounts with independent scopes, session tracking, and lifecycle hooks. (#149)
661
+
662
+ ## [0.13.0] - 2026-03-26
663
+
664
+ ### Added
665
+
666
+ - **Scope-aware lifecycle hooks (RAR-95, RAR-96)** — Named authentication scopes with profile-type gating. `ScopeConfig` and `StandardId.scope_for(name)` define scopes; lifecycle hooks receive scope context. Built-in profile validation runs before custom `before_sign_in` hooks, raising `AuthenticationDenied` when required profile is missing. Configurable `profile_resolver` and per-scope `no_profile_message`. (#145)
667
+ - **Multi-scope session tracking (RAR-97)** — `sign_in_account` accepts `scope_name:` and accumulates scopes in `session[:standard_id_scopes]`. New `current_scope_names` helper exposed to controllers and views. Scopes preserved across session fixation reset, cleared on logout. OAuth callback scope preserved via `state_data`. (#146)
668
+ - **Reusable `PasswordlessFlow` concern (RAR-94)** — Public concern wrapping `PasswordlessStrategy` with `generate_passwordless_otp(username:)` and `verify_passwordless_otp(username:)`. YARD documentation added to `WebAuthentication`, `LifecycleHooks`, and `PasswordlessFlow` for host app adoption. `handle_authentication_denied` falls back gracefully when WebEngine is not mounted. (#147)
669
+
670
+ ## [0.12.0] - 2026-03-25
671
+
672
+ ### Added
673
+
674
+ - CI-driven gem publishing via GitHub Actions trusted publisher
675
+
676
+ ## [0.11.0] - 2026-03-25
677
+
678
+ ### Added
679
+
680
+ - **Pre-authentication lifecycle hook (RAR-73)** — `before_sign_in` callback for pre-session gating. Supports `AuthenticationDenied` rejection before session creation across all auth paths. (#137)
681
+ - Test coverage thresholds with SimpleCov (RAR-27) (#136)
682
+
683
+ ## [0.10.0] - 2026-03-24
684
+
685
+ ### Security
686
+
687
+ - **Rate limiting on all auth endpoints (RAR-51, RAR-60, RAR-56)** — Add Rails 8 built-in `rate_limit` to password login, OTP verification, email/phone verification code generation, API passwordless, and API token endpoints. Configurable limits via `rate_limits` config scope. Includes `RateLimitStore` for lazy cache resolution and `RateLimitHandling` concern for graceful 429 responses with `Retry-After` header. (#129)
688
+
689
+ ### Added
690
+
691
+ - **Post-authentication lifecycle hooks (RAR-73)** — `after_sign_in` and `after_account_created` configurable callbacks. Support redirect overrides, `AuthenticationDenied` rejection, and `first_sign_in?` detection across all auth paths (password, passwordless, social, signup). (#131)
692
+ - **Passwordless account factory callback (RAR-71)** — `passwordless.account_factory` config callable receives `identifier:`, `params:`, `request:` and replaces default `find_or_create_account!` logic. Runs inside transaction for rollback protection. Eliminates monkey-patching in host apps. (#130)
693
+ - **Passwordless registration flow in WebEngine (RAR-74)** — `web.passwordless_registration` config enables automatic account creation during passwordless login. Fires `PASSWORDLESS_ACCOUNT_CREATED` event. Challenge preserved on rejection for retry. (#131)
694
+ - **Extensible JWT session struct (RAR-68)** — Session struct gains `claims` field with full decoded JWT payload. New `oauth.custom_claims` config callable for encoding custom claims into access tokens. Reserved JWT keys protected from override. (#132)
695
+ - **Built-in OTP email delivery (RAR-63)** — `PasswordlessMailer` with HTML + text templates. `passwordless.delivery` config (`:custom` default / `:built_in`), `mailer_from`, `mailer_subject`. Eliminates ~15 lines of event subscriber boilerplate per host app. (#133)
696
+ - **Reusable OTP verification API (RAR-45)** — `StandardId::Passwordless.verify` public method for host apps with custom controllers. Result object with `error_code` symbols (`:invalid_code`, `:expired`, `:max_attempts`, `:not_found`, `:blank_code`, `:account_not_found`, `:server_error`). (#134)
697
+ - `find_existing_account` method on passwordless strategies for account lookup without creation
698
+ - `RateLimitStore` lazy-resolving cache wrapper for rate limiting infrastructure
699
+
700
+ ## [0.9.0] - 2026-03-10
701
+
702
+ ### Security
703
+
704
+ - **Database-backed refresh token revocation with rotation and reuse detection (RAR-49)** — Refresh tokens are now stored in the database with token digest, expiry, and revocation tracking. Each refresh rotates the token (old one revoked, new one issued). Reuse of a rotated token triggers family-wide revocation and emits `OAUTH_REFRESH_TOKEN_REUSE_DETECTED` event.
705
+ - **Enforce PKCE S256 only, reject plain method (RAR-50)** — The insecure PKCE `plain` method is no longer accepted. Only `S256` is supported, per OAuth 2.1 best practices.
706
+ - **Hash PKCE code_challenge at storage time (RAR-58)** — The `code_challenge` column now stores a SHA256 hex digest instead of the raw challenge value, for defense-in-depth against database compromise.
707
+ - **Secure password strength defaults (RAR-59)** — `require_special_chars`, `require_uppercase`, and `require_numbers` now default to `true`. Apps that intentionally want weaker passwords must explicitly set `false`.
708
+
709
+ ### Added
710
+
711
+ - `StandardId::RefreshToken` model with token digest, expiry, revocation, and family chain tracking
712
+ - `StandardId::CleanupExpiredRefreshTokensJob` for periodic cleanup of expired/revoked refresh tokens
713
+ - `StandardId::PasswordStrength` concern for config-driven password complexity validation
714
+ - `OAUTH_REFRESH_TOKEN_REUSE_DETECTED` security event
715
+ - Session `revoke!` now cascades revocation to associated refresh tokens
716
+ - Session `before_destroy` revokes active refresh tokens before deletion
717
+
718
+ ### Changed
719
+
720
+ - **Breaking**: PKCE `plain` method no longer accepted — clients must use `S256`
721
+ - **Breaking**: Password complexity defaults changed from `false` to `true`
722
+ - Refresh tokens now include `jti` claim for database lookup; legacy tokens without `jti` are handled gracefully during migration period
723
+
724
+ ### Migration Required
725
+
726
+ ```bash
727
+ rails standard_id:install:migrations
728
+ rails db:migrate
729
+ ```
730
+
731
+ ## [0.8.1] - 2026-03-24
732
+
733
+ ### Security
734
+
735
+ - **Fix social login account takeover via implicit email linking (RAR-46)** — When a social login returned an email matching an existing identifier from a different provider, the system granted access without verification. Now validates provider ownership with a configurable `link_strategy` (`:strict` default blocks cross-provider linking, `:trust_provider` preserves legacy behavior)
736
+ - Add SSRF protection to `HttpClient` — resolve hostnames before connecting and reject private/loopback IP ranges; fix DNS rebinding by pinning connections to resolved IPs; validate URL scheme (http/https only)
737
+ - Add session fixation protection — call `reset_session` before creating authenticated browser sessions on both login and remember-me flows
738
+ - Filter sensitive OAuth parameters (`code_verifier`, `code_challenge`, `client_secret`, `id_token`, `refresh_token`, `access_token`, `state`, `nonce`, `authorization_code`) from Rails logs via engine initializer
739
+
740
+ ### Added
741
+
742
+ - `StandardId::SocialLinkError` exception with `email` and `provider_name` attributes for host apps to build custom error responses
743
+ - `social.link_strategy` config option (`:strict` or `:trust_provider`)
744
+ - `SOCIAL_LINK_BLOCKED` event in both `SOCIAL_EVENTS` and `SECURITY_EVENTS`
745
+ - `provider` column on `standard_id_identifiers` table (nullable, backfilled on social re-login)
746
+ - `SsrfError` exception class for blocked internal requests
747
+
748
+ ### Migration Required
749
+
750
+ ```bash
751
+ rails standard_id:install:migrations
752
+ rails db:migrate
753
+ ```
754
+
755
+ ## [0.8.0] - 2026-03-23
756
+
757
+ ### Added
758
+
759
+ - Post-authentication lifecycle hooks: `after_sign_in` and `after_account_created` config callbacks for host apps to run custom logic after authentication events (#118)
760
+ - `StandardId::AuthenticationDenied` exception for rejecting sign-ins from hooks, with automatic session revocation and redirect
761
+ - Configurable auth mechanism toggles for WebEngine via `config.web.*` scope — selectively enable/disable password login, passwordless OTP, social login, signup, password reset, email/phone verification, and session management (#119)
762
+ - `WebMechanismGate` concern with `requires_web_mechanism` class method for controller-level enforcement
763
+ - `first_sign_in?` helper in `LifecycleHooks` concern using active session count
764
+
765
+ ### Fixed
766
+
767
+ - Orphaned accounts when `after_sign_in` raises `AuthenticationDenied` during signup or social login — newly created accounts are now cleaned up atomically (#120)
768
+ - Race condition in social login: `RecordNotUnique` on concurrent requests is now rescued with retry
769
+ - `first_sign_in?` now only counts active sessions (excludes expired/revoked)
770
+ - Hardcoded `connection: "email"` in passwordless verify now uses `@otp_data[:connection]`
771
+ - `enforce_web_mechanism!` validates mechanism names with `respond_to?` for actionable errors on typos
772
+ - Removed obsolete brakeman ignore entries, added ignores for hook-controlled redirects
773
+
774
+ ### Deprecated
775
+
776
+ - `passwordless.enabled` config field — use `web.passwordless_login` instead
777
+
778
+ ## [0.7.1] - 2026-03-20
779
+
780
+ ### Added
781
+
782
+ - Configurable `bypass_code` for E2E testing of passwordless verification flows (#113)
783
+
784
+ ## [0.7.0] - 2026-03-19
785
+
786
+ ### Added
787
+
788
+ - `POST /oauth/revoke` endpoint for RFC 7009-compliant token revocation (#108)
789
+ - `GET /.well-known/openid-configuration` endpoint for OIDC discovery (#109)
790
+ - `GET /api/sessions` and `DELETE /api/sessions/:id` endpoints for mobile session management (#110)
791
+ - `OAUTH_TOKEN_REVOKED` event published on successful token revocation
792
+
793
+ ## [0.6.0] - 2026-03-19
794
+
795
+ ### Added
796
+
797
+ - `Account.find_or_create_by_verified_email!` class method for race-safe account creation with verified email identifiers (#107)
798
+ - Publishes `ACCOUNT_CREATING` and `ACCOUNT_CREATED` lifecycle events during account creation
799
+ - Auto-sets `email` column on Account if it exists and isn't already provided
800
+
801
+ ### Changed
802
+
803
+ - Social OAuth callback now only accepts `scope` (singular) parameter per OAuth 2.0 spec; the `scopes` (plural) fallback has been removed (#106)
804
+
805
+ ## [0.5.2] - 2026-03-17
806
+
807
+ ### Added
808
+
809
+ - Configurable `sentry_context` lambda for host apps to supply extra Sentry user context fields (email, username, etc.) without overriding the concern method (#98)
810
+
811
+ ### Fixed
812
+
813
+ - Rescue `ArgumentError` in `skip_host_authorization` when controllers inherit ActionPolicy but haven't called `verify_authorized` (#98)
814
+ - Guard `sentry_context` lambda against nil returns, non-Hash returns, and non-callable config values (#98)
815
+ - Base Sentry context keys (`id`, `session_id`) cannot be overridden by the lambda (#98)
816
+
817
+ ## [0.5.1] - 2026-03-17
818
+
819
+ ### Fixed
820
+
821
+ - Use `skip_verify_authorized` for ActionPolicy framework in `skip_host_authorization`, with `respond_to?` guard for API controllers that don't include ActionPolicy (#96)
822
+ - Guard `SentryContext` against sessions without `id` method (#95)
823
+
824
+ ### Changed
825
+
826
+ - Bump production dependencies (#94)
827
+
828
+ ## [0.5.0] - 2026-03-13
829
+
830
+ ### Added
831
+
832
+ - Engine scope context to events for richer event payloads (#91)
833
+ - Nonce parameter support through authorization code flow (#89)
834
+
835
+ ### Changed
836
+
837
+ - Automate GitHub Releases from CHANGELOG.md on tag push (#90)
838
+
839
+ ## [0.4.0] - 2026-03-12
840
+
841
+ ### Added
842
+
843
+ - Controller auth-skip declarations and `StandardId.skip_host_authorization` for authorization gem integration (RAR-64) (#85)
844
+ - `StandardId::PasswordlessVerificationService` for custom passwordless login UIs (RAR-65) (#84)
845
+ - `StandardId::Testing` support package for host app test suites (RAR-66) (#74)
846
+
847
+ ### Fixed
848
+
849
+ - Thread-safe class-level memoization in `JwtService` and `PasswordFlow` (#87)
850
+
851
+ ## [0.3.2] - 2026-03-11
852
+
853
+ ### Added
854
+
855
+ - Optional `SentryContext` concern for enriching Sentry error reports with auth context (#76)
856
+ - Optional `current_user` alias for `current_account` (#75)
857
+ - `account_scope` configuration for eager-loading account associations (#77)
858
+
859
+ ### Fixed
860
+
861
+ - Normalize IPv6 localhost (`::1`) to `127.0.0.1` for consistent IP handling (#79)
862
+
863
+ ### Changed
864
+
865
+ - Add repo hygiene files: CONTRIBUTING, SECURITY, CODE_OF_CONDUCT (#78)
866
+
867
+ ## [0.3.1] - 2026-03-11
868
+
869
+ ### Added
870
+
871
+ - Bearer token extraction concern for flexible JWT authentication (RAR-48)
872
+ - JWT audience (`aud`) verification on token decode (RAR-48)
873
+ - Expired session cleanup job with configurable grace period (RAR-62)
874
+ - Boot-time warning when JWT issuer is not configured (RAR-54)
875
+
876
+ ### Fixed
877
+
878
+ - Social login now checks provider `email_verified` field before marking emails as verified (RAR-47)
879
+ - Prevent user enumeration via timing side-channel on password login with dummy bcrypt comparison (RAR-53)
880
+ - Replace database error leak in signup with generic message to prevent account enumeration (RAR-61)
881
+ - Remove `account` attribute from `AccountLockedError` to prevent sensitive data exposure (RAR-57)
882
+ - Add HTTP client timeouts (5s open, 10s read) to prevent resource exhaustion from slow OAuth providers (RAR-52)
883
+ - Cap token lifetimes at 24h (access) and 90d (refresh) with log warnings on clamping (RAR-55)
884
+
885
+ ## [0.3.0] - 2026-03-10
886
+
887
+ ### Added
888
+
889
+ - Passwordless OTP login flow for WebEngine (RAR-44)
890
+ - Audit logging documentation for integration with `standard_audit` gem
891
+
892
+ ### Fixed
893
+
894
+ - Disable `strict_loading` on `current_account` in session managers
895
+
896
+ ### Changed
897
+
898
+ - Upgrade to Ruby 4.0.1
899
+ - Standardize GitHub Actions workflows and lefthook git hooks
900
+ - Bump dependencies: sqlite3, puma, brakeman, rspec-rails, shoulda-matchers
901
+
902
+ ## [0.2.9] - 2025-12-02
903
+
904
+ ### Fixed
905
+
906
+ - Add `alg` and `use` fields to JWKS endpoint response
907
+
908
+ ## [0.2.8] - 2025-11-28
909
+
910
+ ### Added
911
+
912
+ - Signing key rotation with zero-downtime support (CORE-164)
913
+
914
+ ## [0.2.7] - 2025-11-20
915
+
916
+ ### Added
917
+
918
+ - Basic auth support for client secret authentication
919
+ - Redirect to `login_page` when not logged in
920
+
921
+ ## [0.2.6] - 2025-11-15
922
+
923
+ ### Added
924
+
925
+ - JWKS endpoint for JWT public key exposure (SWE-701)
926
+
927
+ ## [0.2.5] - 2025-11-10
928
+
929
+ ### Added
930
+
931
+ - Store `aud` on refresh tokens and expose via `current_session`
932
+
933
+ ## [0.2.4] - 2025-11-05
934
+
935
+ ### Added
936
+
937
+ - Refresh token support for social OAuth flow
938
+
939
+ ## [0.2.3] - 2025-10-30
940
+
941
+ ### Added
942
+
943
+ - Scope parameter support in social provider token exchange (SWE-697)
944
+
945
+ ## [0.2.2] - 2025-10-25
946
+
947
+ ### Added
948
+
949
+ - Action Cable authentication support
950
+
951
+ ## [0.2.1] - 2025-10-20
952
+
953
+ ### Added
954
+
955
+ - Login params support in OAuth sign-in flow
956
+
957
+ ## [0.2.0] - 2025-10-15
958
+
959
+ ### Added
960
+
961
+ - Account activation/deactivation with event-driven side effects
962
+ - Account locking/unlocking for administrative security
963
+ - Configurable session expiration
964
+ - Event-driven architecture replacing single callbacks
965
+
966
+ ### Changed
967
+
968
+ - Refactor social provider to prepare for plugin architecture
969
+ - Extract Apple and Google providers into separate gems (`standard_id-apple`, `standard_id-google`)
970
+ - Make gem thread-safe for multi-threaded servers
971
+ - Ensure event payloads are audit-ready for external subscribers
972
+
973
+ ## [0.1.7] - 2025-09-15
974
+
975
+ ### Added
976
+
977
+ - Event-driven architecture for extensibility and observability
978
+
979
+ ## [0.1.6] - 2025-09-01
980
+
981
+ ### Added
982
+
983
+ - Inertia.js support for React/Vue/Svelte frontends
984
+
985
+ ## [0.1.5] - 2025-08-15
986
+
987
+ ### Added
988
+
989
+ - Apple Sign In integration
990
+ - Social login callback support
991
+ - Server-side authorization code flow for mobile
992
+
993
+ ### Fixed
994
+
995
+ - Social callback no longer always required
996
+
997
+ ## [0.1.4] - 2025-08-01
998
+
999
+ ### Added
1000
+
1001
+ - Google OAuth integration
1002
+ - Configurable custom scopes and claims
1003
+
1004
+ ## [0.1.3] - 2025-07-15
1005
+
1006
+ ### Added
1007
+
1008
+ - JWT scope validation in API authentication
1009
+ - Configurable OAuth token expiration
1010
+
1011
+ ## [0.1.2] - 2025-07-01
1012
+
1013
+ ### Fixed
1014
+
1015
+ - Client credential flow bugs
1016
+
1017
+ ## [0.1.1] - 2025-06-15
1018
+
1019
+ ### Changed
1020
+
1021
+ - Initial version bump after core setup
1022
+
1023
+ ## [0.1.0] - 2025-06-01
1024
+
1025
+ ### Added
1026
+
1027
+ - Core authentication engine with web and API dual-mount architecture
1028
+ - Cookie-based web sessions with CSRF protection
1029
+ - JWT-based API authentication
1030
+ - OAuth 2.0 authorization code flow with PKCE support
1031
+ - Implicit, client credentials, and password grant flows
1032
+ - Refresh token flow
1033
+ - Passwordless authentication via email/SMS OTP
1034
+ - STI-based session management (Browser, Device, Service)
1035
+ - STI-based identifiers (Email, Phone, Username)
1036
+ - Client application management with secret rotation
1037
+ - Configuration system with schema DSL
1038
+ - Install generator