standard_id 0.29.1 → 0.31.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,1059 @@
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.31.0] - 2026-07-27
11
+
12
+ ### Fixed
13
+
14
+ - **`EmailIdentifier` now rejects dot-atom violations in the local part.**
15
+ `URI::MailTo::EMAIL_REGEXP` models the local part as one flat character class
16
+ that happens to include `.`, so it accepted dot placements RFC 5322 forbids in
17
+ an unquoted local part — a leading dot, a trailing dot, and consecutive dots.
18
+ `a..b@example.com` passed it. Email service providers do not accept these:
19
+ Postmark rejects them at *send* time with `InvalidEmailRequestError`, so a
20
+ typo accepted at sign-up only surfaced much later as a failed delivery job,
21
+ long after the user could have corrected it. The domain pattern is
22
+ `URI::MailTo`'s own, unchanged; only the local part is stricter.
23
+
24
+ **Consumer impact.** Addresses your app previously accepted may now be
25
+ rejected at the identifier layer. The validation is scoped to a *changed*
26
+ value, so rows created under the looser rule keep saving until someone edits
27
+ the address — an existing account will not be locked out of a flow that merely
28
+ stamps `verified_at`. Check any test fixtures or seeds using addresses with
29
+ doubled or edge dots.
30
+
31
+ ## [0.30.0] - 2026-07-26
32
+
33
+ ### Added
34
+
35
+ - **`config.oauth.strict_redirect_uri_matching`** (default `false`). RFC 6749
36
+ §4.1.3 requires a `redirect_uri` that was present at `/authorize` to be
37
+ repeated, identically, at `/token`; the token endpoint previously only
38
+ compared the values when the client bothered to send one, so omitting the
39
+ parameter skipped the check entirely. Strictness is opt-in so the flip is
40
+ deliberate. While it is off, a code minted **with** a `redirect_uri` that is
41
+ redeemed **without** one logs a warning naming the `client_id` — watch for
42
+ it, then set the flag to `true`. Codes minted without a `redirect_uri` are
43
+ unaffected either way, and a *mismatched* value has always been (and remains)
44
+ rejected.
45
+
46
+ - **`config.oauth.revocation_scope`** (default `:account`, the existing
47
+ behaviour). `POST /oauth/revoke` bulk-revoked **every** active `DeviceSession`
48
+ for the token's subject regardless of which token was presented, so one
49
+ client's logout signed the account out everywhere. Setting it to `:grant`
50
+ narrows revocation to the authorization grant behind the presented token
51
+ (RFC 7009 §2.1): the refresh-token family its `jti` resolves to, plus that
52
+ grant's `Session` when one is linked. A stateless access token resolves to no
53
+ stored artifact and therefore revokes nothing under `:grant` (still `200`,
54
+ per RFC 7009 §2.2) — present the refresh token. An unrecognised value logs a
55
+ warning and falls back to `:account`. `ServiceSession`s remain untouched
56
+ under both settings. The `OAUTH_TOKEN_REVOKED` event payload gains a
57
+ `refresh_tokens_revoked` count alongside `sessions_revoked` (additive).
58
+
59
+ - **`Session.authenticate_by_token`** / **`Session#authenticate_token`**.
60
+ `Session.by_token` matches only the SHA256 `lookup_hash` — an index key, not
61
+ a credential — so every consumer hand-rolled the mandatory BCrypt verify
62
+ against `token_digest` (and the `BCrypt::Errors::InvalidHash` rescue). The
63
+ new entry point does the lookup and a constant-time digest comparison
64
+ (`ActiveSupport::SecurityUtils.secure_compare`), honours the current scope
65
+ (`Session.api_compatible.active.authenticate_by_token(token)`) and returns
66
+ `nil` rather than raising for blank, unknown, mismatched, or malformed-digest
67
+ tokens.
68
+
69
+ ### Fixed
70
+
71
+ - `CHANGELOG.md` is now included in the published gem. The gemspec's file glob
72
+ omitted it, so every release to date shipped without one even though the file
73
+ has always existed in the repo — the only gem in the `standard_*` family
74
+ missing it. No code change; `lib/`, `app/`, `config/`, and `db/` are unaffected.
75
+
76
+ ## [0.29.1] - 2026-07-16
77
+
78
+ ### Fixed
79
+
80
+ - **Signing in during an OAuth flow returned a 500 when `use_inertia` is on.**
81
+ With `config.use_inertia = true`, the WebEngine's auth pages are Inertia
82
+ components, so their form submits are Inertia XHRs. The three
83
+ post-authentication redirects — passwordless OTP verify, password login, and
84
+ signup — used a plain `redirect_to` to send the user on to whatever was
85
+ stashed in `session[:return_to_after_authenticating]`. When that destination
86
+ is a *non*-Inertia controller (the ApiEngine's `/api/authorize` in an OAuth
87
+ round-trip), the Inertia client follows the redirect with the `X-Inertia`
88
+ header still attached, and `inertia_rails`' middleware raises
89
+ `NoMethodError: undefined method 'inertia_configuration'` — a 500 that broke
90
+ MCP/OAuth sign-in outright.
91
+
92
+ The root asymmetry: `inertia_rails` mixes its controller module in via
93
+ `on_load(:action_controller_base)`, so `Web::BaseController`
94
+ (`ActionController::Base`) gets `inertia_configuration` while
95
+ `Api::BaseController` (`ActionController::API`) never does.
96
+
97
+ All three sites now route through a shared `redirect_after_authentication`
98
+ helper on `Web::BaseController`, which uses the existing
99
+ `InertiaSupport#redirect_with_inertia` — already used for social login and
100
+ signup — to emit a 409 + `X-Inertia-Location` so the browser performs a real
101
+ page visit. Note this is keyed on the *request* being Inertia, not on the
102
+ destination being cross-origin: the destination in the OAuth case is
103
+ same-origin, so an external-only check would not have fixed it.
104
+
105
+ The flash notice is now written before redirecting so it survives the
106
+ Inertia branch (which cannot carry `redirect_to`'s `notice:` option).
107
+
108
+ - **Post-auth redirects to allow-listed deep links raised
109
+ `UnsafeRedirectError`.** `safe_destination?` admits configured non-HTTP
110
+ schemes (e.g. `myapp://`), but `redirect_to` rejects them without
111
+ `allow_other_host:`. Now passed conditionally — mirroring
112
+ `ProvidersController` — so Rails' same-origin backstop still applies to
113
+ everything else. `safe_destination?` itself is unchanged.
114
+
115
+ ## [0.29.0] - 2026-07-15
116
+
117
+ ### Fixed
118
+
119
+ - **A rate-limited GET no longer drives an unbounded redirect loop.** The shared
120
+ rate-limit handler bounced every tripped action to `request.path`. That is
121
+ safe for a POST/PATCH (its sibling GET is a different action), but v0.28.0
122
+ shipped the first rate-limited GETs — the email/phone verification-code
123
+ *confirm* `#show` actions — so a tripped GET redirected to *itself*: the
124
+ browser followed the redirect, re-incremented the counter, and got redirected
125
+ again, an unbounded loop the victim's browser drives that also permanently
126
+ resets the window. The handler now renders a terminal `429 Too Many Requests`
127
+ on GET/HEAD (no redirect), and keeps the existing same-path redirect for
128
+ non-GET web actions. API responses are unchanged.
129
+ - **The `Retry-After` header now reflects the tripped limit's real window.** It
130
+ was hardcoded to 15 minutes, which was 4x too short for every 1-hour limit
131
+ (`verification_start_per_ip`, `password_reset_start_per_ip`, `signup_per_ip`,
132
+ `api_passwordless_start_per_ip`, `dynamic_registration_per_ip`). Each
133
+ `rate_limit` declaration's `within:` is now threaded to the handler (captured
134
+ in the per-limit `with:` closure the instant that limit trips, since
135
+ `ActionController::TooManyRequests` carries no window and a controller may
136
+ declare several limits with different windows), so `Retry-After` matches the
137
+ window that actually fired. A hand-rolled `raise` that bypasses the macro
138
+ falls back to 15 minutes.
139
+ - **Blank per-target rate-limit keys no longer collapse into one shared bucket.**
140
+ Five per-target limiters interpolated a param that can be blank, yielding a
141
+ stable key like `"reset-password:"` — a single global bucket (Rails'
142
+ `.compact` does not drop a non-nil empty string), so blank-target spam from
143
+ one source throttled every legitimate user. Affected: web login (by email),
144
+ API passwordless start (by target), email/phone verification start (by
145
+ target), and password-reset start (by email). Each now falls the key back to
146
+ the remote IP when the target is blank, keeping blank spam bounded per-IP
147
+ without poisoning real targets' buckets — mirroring the existing per-audience
148
+ token limiter's "only count well-formed values" shape.
149
+
150
+ ### Added
151
+
152
+ - **Mechanism-agnostic login rate-limit config: `rate_limits.login_per_ip`
153
+ (default 20) and `rate_limits.login_per_email` (default 5).** The login
154
+ `#create` action branches password OR passwordless, so on a passwordless app
155
+ the existing `password_login_per_*` fields actually govern the OTP-send limit
156
+ — a misnomer that led four consumer apps to write false config comments. The
157
+ new names describe the action, not a mechanism. When unset they inherit the
158
+ deprecated `password_login_*` values, so existing behaviour is unchanged; when
159
+ set they win.
160
+
161
+ ### Deprecated
162
+
163
+ - **`rate_limits.password_login_per_ip` / `rate_limits.password_login_per_email`
164
+ are deprecated in favour of `login_per_ip` / `login_per_email`.** They remain
165
+ fully honoured (the config schema rejects unknown fields at boot, so a host
166
+ still setting the old names keeps working); the login controller reads the new
167
+ alias and falls back to the deprecated field when the alias is left at its
168
+ default. Follows the `max_attempts` → `max_attempts_per_challenge` alias
169
+ precedent.
170
+
171
+ ## [0.28.0] - 2026-07-12
172
+
173
+ ### Added
174
+
175
+ - **Rate limiting on the last unprotected auth surfaces.** The web
176
+ password-reset request (`reset_password/start`), password signup, and the
177
+ email/phone code-*confirmation* endpoints now carry Rails-native
178
+ `rate_limit`s, closing email-flooding, account-enumeration,
179
+ account-creation-spam, and distributed code-guessing gaps. New config keys:
180
+ `rate_limits.password_reset_start_per_ip` (10/hr),
181
+ `rate_limits.password_reset_start_per_target` (3/15min), and
182
+ `rate_limits.signup_per_ip` (10/hr); the confirm endpoints reuse
183
+ `otp_verify_per_ip`. All use the existing `RateLimitStore` + centralized 429
184
+ handler.
185
+ - **The `passwordless.retry_delay` OTP-resend cooldown is now enforced.**
186
+ Previously the setting existed but did nothing. A resend for the same target
187
+ within the window (default 30s) is rejected with an `InvalidRequestError`;
188
+ the already-issued code stays valid. Set `retry_delay = 0` to disable.
189
+
190
+ ## [0.27.0] - 2026-07-03
191
+
192
+ ### Added
193
+
194
+ - **Loopback redirect URIs match on any port for public PKCE clients (RFC 8252
195
+ §7.3).** Native apps receive the authorization response on an ephemeral
196
+ local listener whose port cannot be known at registration time. When a
197
+ public client with `require_pkce` presents a redirect URI whose scheme is
198
+ `http` and whose host is a loopback literal (`127.0.0.1`, `::1`, or
199
+ `localhost`), and a registered redirect URI is likewise a loopback URI, the
200
+ comparison now ignores the port — host and path must still match exactly, so
201
+ `127.0.0.1` and `localhost` do not cross-match (RFC 8252 §8.3 recommends the
202
+ IP literals over `localhost`). Confidential clients and non-loopback URIs
203
+ keep strict scheme+host+port+path matching. Registration-time validation is
204
+ unchanged. Token exchange is unaffected: the redirect URI presented at the
205
+ token endpoint is still compared byte-for-byte against the value stored when
206
+ the code was issued.
207
+
208
+ ## [0.26.4] - 2026-06-22
209
+
210
+ ### Fixed
211
+
212
+ - **WebEngine controller redirects no longer drop the mount prefix on non-root
213
+ mounts.** Isolated-engine `_path` helpers are mount-relative, and
214
+ `redirect_to` / `redirect_with_inertia` (unlike `form_with` / `url_for` view
215
+ URL generation) do not prepend the mount's `SCRIPT_NAME`. So when the engine
216
+ was mounted at a non-root path (e.g. `mount StandardId::WebEngine => "/auth"`),
217
+ redirects produced prefix-less paths that 404'd — most visibly `POST
218
+ /auth/login` redirecting to `/login_verify` instead of `/auth/login_verify`,
219
+ breaking passwordless login. Form actions were never affected. All affected
220
+ redirects (`login` → `login_verify`; `login_verify` / reset-password →
221
+ `login`; session revoke → `sessions`; account update → `account`; and the
222
+ `after_sign_in` denial bounce) now prepend `request.script_name` via a new
223
+ `engine_path` helper (a no-op for root mounts). Regression test added to
224
+ `spec/integration/multi_mount_spec.rb`.
225
+
226
+ ## [0.26.3] - 2026-06-15
227
+
228
+ ### Fixed
229
+
230
+ - **Unauthenticated access to a protected web page no longer 500s.** The web
231
+ authentication guard (`require_browser_session!`) raises
232
+ `NotAuthenticatedError` / `InvalidSessionError` (for missing / expired /
233
+ revoked sessions) rather than redirecting. The API base controller rescued
234
+ these, but the web base controller did not — so an unauthenticated request to
235
+ a protected web page (e.g. `/sessions`) surfaced as a 500 instead of bouncing
236
+ to login. The web base controller now rescues both and redirects to the login
237
+ page, preserving the original destination.
238
+
239
+ ## [0.26.2] - 2026-06-15
240
+
241
+ ### Security
242
+
243
+ - **OTP codes and password-reset tokens no longer leak into the logs.** The
244
+ built-in mailers (`PasswordlessMailer`, `PasswordResetMailer`) pass the OTP
245
+ code / reset URL as mailer params, which `deliver_later` serializes as the
246
+ delivery job's arguments — and ActiveJob's log subscriber prints job arguments
247
+ in plaintext on enqueue/perform (e.g. `params: {email:…, otp_code: "03158369"}`).
248
+ StandardId's mailers now deliver via `StandardId::SecureMailDeliveryJob`
249
+ (`ActionMailer::MailDeliveryJob` with `log_arguments = false`), so the
250
+ arguments are kept out of the log stream. Delivery behaviour is unchanged.
251
+
252
+ ## [0.26.1] - 2026-06-15
253
+
254
+ ### Fixed
255
+
256
+ - **Passwordless `login_verify` OTP input now respects
257
+ `config.passwordless.code_length`.** The built-in ERB verification-code field
258
+ hardcoded `maxlength: 6`, so apps configuring a longer code (e.g.
259
+ `code_length = 8`) rendered a field that truncated input to 6 characters —
260
+ users could not enter the full code. The input now derives `maxlength` from
261
+ `StandardId::Passwordless.otp_code_length` (the same clamped 4..10 value the
262
+ OTP generator uses), exposed to views via a new
263
+ `StandardId::ApplicationHelper#otp_code_length` helper, so the form and the
264
+ generated code stay in sync end-to-end.
265
+ - **WebEngine rate-limit responses no longer 500 on hosts without a root
266
+ route.** When a web auth action (login, login_verify, email/phone verification
267
+ start) hit its rate limit, the handler redirected to
268
+ `request.referer || main_app.root_path`. That raised — and returned a 500
269
+ error page instead of the intended graceful response — for any host app that
270
+ doesn't define a `root` route (e.g. an API/control-plane that only mounts the
271
+ engine), and also for cross-origin `Referer` headers (Rails' open-redirect
272
+ guard). The handler now redirects back to the rate-limited form's own path
273
+ (`request.path`), which is always a valid same-origin GET. The ApiEngine
274
+ responses (JSON `429`) are unchanged.
275
+ - **OAuth/OIDC metadata no longer advertises a `jwks_uri` under symmetric
276
+ signing.** With the default HS256 (and HS384/HS512) there are no public keys
277
+ to publish, so the JWKS endpoint deliberately returns 404 — but the
278
+ authorization-server and openid-configuration documents advertised `jwks_uri`
279
+ unconditionally, pointing clients at a dead URL. `jwks_uri` is now emitted only
280
+ when signing is asymmetric (RS*/ES*). RFC 8414 makes it optional; HS-signed
281
+ tokens are verified with the shared secret, not JWKS.
282
+ - **Sign-out / unauthenticated requests no longer leave a non-HttpOnly
283
+ `session_token` cookie.** `clear_session!` assigned
284
+ `cookies.encrypted[:session_token] = nil`, which wrote a fresh encrypted blob
285
+ through the cookie jar's default options (no `HttpOnly`) on every
286
+ unauthenticated request. It now uses `cookies.delete(:session_token)` to remove
287
+ the cookie cleanly. The token-bearing sign-in write was already `httponly: true`
288
+ and is unchanged, so this is a hygiene/consistency fix, not a token exposure.
289
+
290
+ ## [0.26.0] - 2026-06-15
291
+
292
+ ### Added
293
+
294
+ - **`config.passwordless.production_env_detector`** — an optional callable that
295
+ decides whether the current deploy counts as "production" for the bypass-code
296
+ guard. When `nil` (default) the gem falls back to `Rails.env.production?`, so
297
+ existing consumers are unchanged. Apps that distinguish a physical deploy
298
+ environment from `RAILS_ENV` (e.g. a staging box still running
299
+ `RAILS_ENV=production`) can supply `-> { AppEnv.production? }` to permit a
300
+ `bypass_code` on staging while it stays refused on real production. The guard
301
+ in `Passwordless::VerificationService` (which also backs `Otp.verify`) now
302
+ defers to this detector instead of checking `Rails.env.production?` directly.
303
+
304
+ ## [0.25.0] - 2026-06-13
305
+
306
+ ### Changed
307
+
308
+ - **Browser session cookie now persists across browser restarts.** The
309
+ encrypted `session_token` cookie is written with an explicit `expires` tied
310
+ to the `BrowserSession#expires_at` (previously a bare session cookie that was
311
+ cleared on full browser close, logging users out well before their session
312
+ actually expired). The cookie is also hardened with `httponly: true`,
313
+ `same_site: :lax`, and `secure` following `request.ssl?`. Combined with a
314
+ host-configured `session.browser_session_lifetime`, this lets a "remember me
315
+ for N days" session survive closing and reopening the browser. Applies to
316
+ both the sign-in and remember-token re-auth paths.
317
+
318
+ ## [0.24.0] - 2026-06-13
319
+
320
+ ### Added
321
+
322
+ - **Public-client (PKCE) support at `POST /oauth/token` for the
323
+ `authorization_code` grant.** Public clients (native/SPA/MCP clients per
324
+ RFC 8252 / OAuth 2.1) can now exchange an authorization code for tokens
325
+ using PKCE alone, with no `client_secret`. Confidential clients still
326
+ authenticate with a secret exactly as before (regression-safe). The flow
327
+ looks up the `ClientApplication` by `client_id`, validates a secret only
328
+ for confidential clients, rejects a public client that sends a
329
+ `client_secret` (`invalid_client`), and **fails closed** when a public
330
+ client's authorization code carries no `code_challenge` — PKCE is the
331
+ client's only authentication factor, so a code minted without one is
332
+ rejected with `invalid_grant`. `"none"` is now advertised in
333
+ `token_endpoint_auth_methods_supported` in both discovery documents.
334
+ - **`oauth.dynamic_registration_default_auth_method` config** (default
335
+ `"none"`). Controls the `token_endpoint_auth_method` applied to clients
336
+ created via RFC 7591 Dynamic Client Registration when the request omits
337
+ one — i.e. whether self-registered clients default to public (PKCE-only)
338
+ or confidential (secret-bearing). Validated at use against
339
+ `none` / `client_secret_basic` / `client_secret_post`; an out-of-range
340
+ value raises `ConfigurationError`. Default preserves existing behaviour.
341
+
342
+ ### Fixed
343
+
344
+ - **Consent screen now completes for Inertia-rendered hosts.** When a host
345
+ renders the OAuth consent screen via Inertia (`use_inertia`), the
346
+ approve/deny decision arrives as an Inertia XHR, which cannot follow a 302
347
+ to the external client `redirect_uri` — the browser would hang on the
348
+ consent screen. `ConsentController` now emits an Inertia-Location
349
+ (`409` + `X-Inertia-Location`) for Inertia requests so the client performs a
350
+ hard navigation to the callback, while plain (ERB) form posts keep the
351
+ ordinary redirect. No effect on non-Inertia hosts.
352
+
353
+ ## [0.23.0] - 2026-06-12
354
+
355
+ ### Added
356
+
357
+ - **Per-audience rate limits at `POST /oauth/token`** — new
358
+ `rate_limits.api_token_per_audience_per_ip` config (Hash of
359
+ audience => max requests per IP per 15 minutes, default `{}`). Lets hosts
360
+ tighten the cap for higher-risk audiences (e.g. a public mobile app) while
361
+ internal/partner audiences keep the global `api_token_per_ip` ceiling. A
362
+ request must pass both its audience cap and the global cap. Implemented as
363
+ an explicit `before_action` counter rather than the Rails `rate_limit`
364
+ DSL: the DSL counts every request reaching the action, and a `by:` block
365
+ returning `nil` does not exempt a request — it collapses into a shared
366
+ bucket keyed without the discriminator, so one audience's rule would
367
+ throttle every other audience's traffic. Only requests that actually
368
+ target a configured audience increment that audience's per-IP counter.
369
+ Exceeding the cap renders the standard `rate_limit_exceeded` JSON error
370
+ with `Retry-After`.
371
+
372
+ ### Fixed
373
+
374
+ - **`SOCIAL_AUTH_FAILED` is now emitted on the API (mobile) callback path
375
+ too.** Since 0.16.0 the event fired only from the web callback
376
+ (`Web::Auth::Callback::ProvidersController`); on
377
+ `POST /api/oauth/callback/:provider` an infrastructure-level provider
378
+ failure (`StandardId::OAuthError` from the provider call) fell through to
379
+ the standard `handle_oauth_error` JSON response without emitting, so host
380
+ apps observing provider outages via the event were blind on the API flow
381
+ (and had to monkey-patch `get_user_info_from_provider` to compensate).
382
+ The rescue is scoped to the provider call: `OAuthError` subclasses raised
383
+ later in the flow (`SocialLinkError`, `InvalidRequestError`, ...) are
384
+ policy/client errors and still do not emit. The JSON error response is
385
+ unchanged.
386
+
387
+ ## [0.22.0] - 2026-06-11
388
+
389
+ ### Added
390
+
391
+ - **RFC 7591 Dynamic Client Registration** behind a default-off toggle. New
392
+ endpoint `POST /oauth/register`
393
+ (`Api::Oauth::RegistrationsController` -> `StandardId::Oauth::ClientRegistration`)
394
+ lets clients self-register OAuth client applications.
395
+ - **Rate limited.** Throttled by IP via
396
+ `rate_limits.dynamic_registration_per_ip` (default 10/hour) so an enabled
397
+ deployment can't be flooded with client rows.
398
+ - **Default off.** Gated on `oauth.dynamic_registration_enabled` (default
399
+ `false`). While off, the endpoint returns **404** (fully absent, not just a
400
+ guarded 403) and the discovery documents do **not** advertise a
401
+ `registration_endpoint`. An open, unauthenticated registration endpoint is
402
+ state-mutating attack surface, so it is strictly opt-in.
403
+ - **Owner resolver.** When enabled, set
404
+ `oauth.dynamic_registration_owner` to a callable resolving the polymorphic
405
+ owner for registered clients (e.g. `-> { Organization.default }`). If the
406
+ toggle is on but the resolver is nil/returns nil, registration raises a
407
+ clear `StandardId::ConfigurationError` rather than silently failing model
408
+ validation.
409
+ - **Metadata -> ClientApplication mapping** (RFC 7591 §2): `redirect_uris`
410
+ (REQUIRED — empty/invalid yields `invalid_redirect_uri`), `client_name` ->
411
+ `name` (a name is generated when absent), `scope` (default
412
+ `"openid profile email"`). `grant_types` is whitelisted to
413
+ `authorization_code`/`refresh_token` and `response_types` to `code` (others
414
+ rejected as `invalid_client_metadata`). `token_endpoint_auth_method` `none`
415
+ -> **public** client; `client_secret_basic`/`client_secret_post` ->
416
+ **confidential** (a one-time `client_secret` is generated and returned with
417
+ `client_secret_expires_at: 0`). Default auth method is `none` (public).
418
+ - **Forced security defaults.** All registered clients are forced onto
419
+ `require_pkce: true` + `code_challenge_methods: "S256"` (the model also
420
+ validates this for public clients). Registered clients default to
421
+ `require_consent: true` — they get the HTML consent screen shipped in
422
+ 0.21.0 rather than the old `require_consent: false` workaround.
423
+ - **Discovery advertisement.** Both `/.well-known/openid-configuration` and
424
+ `/.well-known/oauth-authorization-server` advertise
425
+ `registration_endpoint` **only when** `oauth.dynamic_registration_enabled`
426
+ is true (the flag is now read from config and passed into
427
+ `DiscoveryDocument.build`).
428
+ - Responses follow RFC 7591 §3.2.1 (HTTP 201) on success and §3.2.2 (HTTP
429
+ 400, `invalid_redirect_uri` / `invalid_client_metadata`) on error. No
430
+ migration required — all `ClientApplication` columns already exist.
431
+
432
+ ## [0.21.1] - 2026-06-11
433
+
434
+ ### Fixed
435
+
436
+ - **ERB login view now respects the `web.signup` and `web.password_reset`
437
+ toggles.** The packaged login view rendered an unconditional "Sign up" link
438
+ (both the passwordless and password branches) and a "Forgot password?" link
439
+ (password branch), so an app with `web.signup = false` or
440
+ `web.password_reset = false` showed links to routes that 404. The links are
441
+ now gated on their respective toggles. No effect on apps that leave the
442
+ toggles at their defaults.
443
+
444
+ ## [0.21.0] - 2026-06-11
445
+
446
+ ### Added
447
+
448
+ - **RFC 8414 OAuth 2.0 Authorization Server Metadata** — new endpoint
449
+ `/.well-known/oauth-authorization-server` (`Api::WellKnown::OauthAuthorizationServerController`),
450
+ serving the same document as `/.well-known/openid-configuration`. Both
451
+ controllers now render a single shared builder,
452
+ `StandardId::Oauth::DiscoveryDocument.build(issuer, registration_enabled: false)`,
453
+ so the OIDC and OAuth metadata documents cannot drift.
454
+ - **Mount caveat:** the ApiEngine is consumer-mounted at a sub-path (e.g.
455
+ `/auth/api`), so the gem can only serve this at
456
+ `/auth/api/.well-known/oauth-authorization-server`. A strict RFC 8414 client
457
+ that derives a *root-anchored* URL from a path-carrying issuer
458
+ (`<host>/.well-known/oauth-authorization-server/auth/api`) lands outside any
459
+ engine mount; hosts needing the root-anchored form must add their own root
460
+ route — the gem cannot. The `registration_endpoint` is intentionally NOT
461
+ emitted yet; the `registration_enabled:` kwarg is a seam for Phase 2 (DCR).
462
+ - **PKCE advertisement** — both discovery documents now advertise
463
+ `code_challenge_methods_supported: ["S256"]` (always on; PKCE is always
464
+ enforced).
465
+ - **HTML consent view for the authorization-code flow** — an authenticated,
466
+ interactive (HTML) `/authorize` for a client with `require_consent` enabled
467
+ and no prior grant is now handed off to a new WebEngine consent screen
468
+ (`GET/POST /consent`, asset-free ERB; Inertia consumers receive props for
469
+ their own component) instead of dead-ending. On approve, a `ClientGrant` is
470
+ recorded and the authorization code is issued by re-running the same
471
+ authorization-code flow (so `redirect_uri` and PKCE are revalidated, not
472
+ duplicated); on deny, the user is redirected back with `error=access_denied`
473
+ (+ `state`). Repeat authorizations with a matching grant skip consent. The
474
+ API authorize endpoint carries the original `/authorize` params to the
475
+ consent screen through a signed, expiring payload
476
+ (`StandardId::Oauth::ConsentPayload`, mirroring the OTP `message_verifier`
477
+ pattern). New table `standard_id_client_grants` (one row per account+client).
478
+ JSON / non-interactive / implicit / social-login flows are unaffected.
479
+
480
+ ### Changed
481
+
482
+ - **`audience` is now OPTIONAL at the authorization-code `/authorize`** — moved
483
+ from `expect_params` to `permit_params` in
484
+ `AuthorizationCodeAuthorizationFlow`. Token-time validation already no-ops on a
485
+ blank audience (or when no `allowed_audiences` are configured), so omitting it
486
+ is safe and lets standards-compliant clients (e.g. MCP) authorize without it.
487
+ `client_credentials` still REQUIRES `audience` (unchanged). This is a
488
+ relaxation, not a break.
489
+ - **Passwordless-aware ERB login view** — the gem's ERB login view now selects
490
+ its form using the same passwordless-first precedence the controller's
491
+ `#create` uses: passwordless-only renders an asset-free email-only form (no
492
+ external `tailwindcss.com` logo, no Tailwind-utility dependence, so it renders
493
+ under a minimal element-CSS layout); password mode renders the existing form
494
+ unchanged (password consumers are unaffected); neither-enabled renders a "No
495
+ login method is enabled" message instead of a 500. Social login still renders
496
+ in both modes when configured.
497
+
498
+ ### Migration notes
499
+
500
+ - Run the new `CreateStandardIdClientGrants` migration (adds
501
+ `standard_id_client_grants`). No existing columns change.
502
+
503
+ ## [0.20.1] - 2026-05-24
504
+
505
+ ### Added
506
+
507
+ - **`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`).
508
+ - **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.
509
+
510
+ ### Fixed
511
+
512
+ - **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.
513
+ - **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.
514
+ - **`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.
515
+ - **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.
516
+ - **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.
517
+ - **Defensive nil-guard on `state_data['redirect_uri']`** in the social callback — uses `state_data&.dig("redirect_uri").presence` consistent with the rescue paths.
518
+
519
+ ## [0.20.0] - 2026-05-21
520
+
521
+ ### Changed (BREAKING — behavior)
522
+
523
+ - **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.
524
+ - **`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.
525
+
526
+ ### Added
527
+
528
+ - `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.
529
+ - **`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.
530
+ - `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.
531
+
532
+ ### Migration notes
533
+
534
+ 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:
535
+
536
+ 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.
537
+ 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.
538
+
539
+ 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.
540
+
541
+ ## [0.19.0] - 2026-05-19
542
+
543
+ ### Added
544
+
545
+ - **`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.
546
+
547
+ ## [0.18.0] - 2026-05-19
548
+
549
+ ### Changed
550
+
551
+ - 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.
552
+
553
+ ## [0.17.1] - 2026-05-07
554
+
555
+ ### Fixed
556
+
557
+ - **`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)
558
+
559
+ ## [0.17.0] - 2026-04-29
560
+
561
+ ### Changed
562
+
563
+ - 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.
564
+ - **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.
565
+ - 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.
566
+
567
+ ### Added
568
+
569
+ - **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.
570
+
571
+ ### Fixed
572
+
573
+ - **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.
574
+
575
+ ### Removed
576
+
577
+ - **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.
578
+
579
+ ## [0.16.1] - 2026-04-19
580
+
581
+ ### Performance
582
+
583
+ - **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)
584
+ - **`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)
585
+ - **`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)
586
+
587
+ ### Added
588
+
589
+ - **`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)
590
+ - **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)
591
+
592
+ ## [0.16.0] - 2026-04-19
593
+
594
+ ### Security
595
+
596
+ - **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)
597
+ - **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)
598
+ - **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)
599
+ - **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)
600
+ - **Hardened GitHub Actions workflows** — minimal `permissions:` blocks added to every workflow; third-party actions pinned to commit SHAs. (#185)
601
+
602
+ ### Added
603
+
604
+ - **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)
605
+ - **`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)
606
+ - **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)
607
+ - **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)
608
+ - **`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)
609
+ - **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)
610
+ - **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)
611
+
612
+ ### Performance
613
+
614
+ - **`first_sign_in?` uses `.exists?` instead of `.count`** on the `LifecycleHooks` hot path — removes a full count on every login. (#172)
615
+ - **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)
616
+ - **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)
617
+ - **Isolate `SESSION_REVOKED` subscriber failures during bulk revoke** — a failing subscriber no longer aborts the revocation loop. (#172)
618
+
619
+ ### Deprecated
620
+
621
+ - **`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)
622
+
623
+ ### Changed
624
+
625
+ - **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)
626
+
627
+ ### Chore
628
+
629
+ - Deleted stale top-level `test_authorization_flows.rb` scaffolding. (#173)
630
+
631
+ ## [0.15.0] - 2026-04-18
632
+
633
+ ### Added
634
+
635
+ - **`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)
636
+ - **`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)
637
+ - **`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)
638
+ - **`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)
639
+ - **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)
640
+ - **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)
641
+ - **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)
642
+
643
+ ### Deprecated
644
+
645
+ - **`ScopeConfig#profile_type` singular** — Use `profile_types:` (plural) instead. Singular still accepted, emits an `ActiveSupport::Deprecation` warning. Planned for removal in 2.0. (#178)
646
+
647
+ ## [0.14.4] - 2026-04-14
648
+
649
+ ### Fixed
650
+
651
+ - **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)
652
+
653
+ ### Changed
654
+
655
+ - Bump puma from 7.2.0 to 8.0.0 (#162)
656
+ - Group all Dependabot updates including majors (#163)
657
+
658
+ ## [0.14.3] - 2026-04-02
659
+
660
+ ### Fixed
661
+
662
+ - **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)
663
+
664
+ ## [0.14.2] - 2026-04-02
665
+
666
+ ### Fixed
667
+
668
+ - **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)
669
+
670
+ ## [0.14.1] - 2026-03-28
671
+
672
+ ### Fixed
673
+
674
+ - **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)
675
+
676
+ ## [0.14.0] - 2026-03-26
677
+
678
+ ### Added
679
+
680
+ - **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)
681
+ - **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)
682
+
683
+ ## [0.13.0] - 2026-03-26
684
+
685
+ ### Added
686
+
687
+ - **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)
688
+ - **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)
689
+ - **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)
690
+
691
+ ## [0.12.0] - 2026-03-25
692
+
693
+ ### Added
694
+
695
+ - CI-driven gem publishing via GitHub Actions trusted publisher
696
+
697
+ ## [0.11.0] - 2026-03-25
698
+
699
+ ### Added
700
+
701
+ - **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)
702
+ - Test coverage thresholds with SimpleCov (RAR-27) (#136)
703
+
704
+ ## [0.10.0] - 2026-03-24
705
+
706
+ ### Security
707
+
708
+ - **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)
709
+
710
+ ### Added
711
+
712
+ - **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)
713
+ - **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)
714
+ - **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)
715
+ - **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)
716
+ - **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)
717
+ - **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)
718
+ - `find_existing_account` method on passwordless strategies for account lookup without creation
719
+ - `RateLimitStore` lazy-resolving cache wrapper for rate limiting infrastructure
720
+
721
+ ## [0.9.0] - 2026-03-10
722
+
723
+ ### Security
724
+
725
+ - **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.
726
+ - **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.
727
+ - **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.
728
+ - **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`.
729
+
730
+ ### Added
731
+
732
+ - `StandardId::RefreshToken` model with token digest, expiry, revocation, and family chain tracking
733
+ - `StandardId::CleanupExpiredRefreshTokensJob` for periodic cleanup of expired/revoked refresh tokens
734
+ - `StandardId::PasswordStrength` concern for config-driven password complexity validation
735
+ - `OAUTH_REFRESH_TOKEN_REUSE_DETECTED` security event
736
+ - Session `revoke!` now cascades revocation to associated refresh tokens
737
+ - Session `before_destroy` revokes active refresh tokens before deletion
738
+
739
+ ### Changed
740
+
741
+ - **Breaking**: PKCE `plain` method no longer accepted — clients must use `S256`
742
+ - **Breaking**: Password complexity defaults changed from `false` to `true`
743
+ - Refresh tokens now include `jti` claim for database lookup; legacy tokens without `jti` are handled gracefully during migration period
744
+
745
+ ### Migration Required
746
+
747
+ ```bash
748
+ rails standard_id:install:migrations
749
+ rails db:migrate
750
+ ```
751
+
752
+ ## [0.8.1] - 2026-03-24
753
+
754
+ ### Security
755
+
756
+ - **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)
757
+ - 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)
758
+ - Add session fixation protection — call `reset_session` before creating authenticated browser sessions on both login and remember-me flows
759
+ - 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
760
+
761
+ ### Added
762
+
763
+ - `StandardId::SocialLinkError` exception with `email` and `provider_name` attributes for host apps to build custom error responses
764
+ - `social.link_strategy` config option (`:strict` or `:trust_provider`)
765
+ - `SOCIAL_LINK_BLOCKED` event in both `SOCIAL_EVENTS` and `SECURITY_EVENTS`
766
+ - `provider` column on `standard_id_identifiers` table (nullable, backfilled on social re-login)
767
+ - `SsrfError` exception class for blocked internal requests
768
+
769
+ ### Migration Required
770
+
771
+ ```bash
772
+ rails standard_id:install:migrations
773
+ rails db:migrate
774
+ ```
775
+
776
+ ## [0.8.0] - 2026-03-23
777
+
778
+ ### Added
779
+
780
+ - Post-authentication lifecycle hooks: `after_sign_in` and `after_account_created` config callbacks for host apps to run custom logic after authentication events (#118)
781
+ - `StandardId::AuthenticationDenied` exception for rejecting sign-ins from hooks, with automatic session revocation and redirect
782
+ - 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)
783
+ - `WebMechanismGate` concern with `requires_web_mechanism` class method for controller-level enforcement
784
+ - `first_sign_in?` helper in `LifecycleHooks` concern using active session count
785
+
786
+ ### Fixed
787
+
788
+ - Orphaned accounts when `after_sign_in` raises `AuthenticationDenied` during signup or social login — newly created accounts are now cleaned up atomically (#120)
789
+ - Race condition in social login: `RecordNotUnique` on concurrent requests is now rescued with retry
790
+ - `first_sign_in?` now only counts active sessions (excludes expired/revoked)
791
+ - Hardcoded `connection: "email"` in passwordless verify now uses `@otp_data[:connection]`
792
+ - `enforce_web_mechanism!` validates mechanism names with `respond_to?` for actionable errors on typos
793
+ - Removed obsolete brakeman ignore entries, added ignores for hook-controlled redirects
794
+
795
+ ### Deprecated
796
+
797
+ - `passwordless.enabled` config field — use `web.passwordless_login` instead
798
+
799
+ ## [0.7.1] - 2026-03-20
800
+
801
+ ### Added
802
+
803
+ - Configurable `bypass_code` for E2E testing of passwordless verification flows (#113)
804
+
805
+ ## [0.7.0] - 2026-03-19
806
+
807
+ ### Added
808
+
809
+ - `POST /oauth/revoke` endpoint for RFC 7009-compliant token revocation (#108)
810
+ - `GET /.well-known/openid-configuration` endpoint for OIDC discovery (#109)
811
+ - `GET /api/sessions` and `DELETE /api/sessions/:id` endpoints for mobile session management (#110)
812
+ - `OAUTH_TOKEN_REVOKED` event published on successful token revocation
813
+
814
+ ## [0.6.0] - 2026-03-19
815
+
816
+ ### Added
817
+
818
+ - `Account.find_or_create_by_verified_email!` class method for race-safe account creation with verified email identifiers (#107)
819
+ - Publishes `ACCOUNT_CREATING` and `ACCOUNT_CREATED` lifecycle events during account creation
820
+ - Auto-sets `email` column on Account if it exists and isn't already provided
821
+
822
+ ### Changed
823
+
824
+ - Social OAuth callback now only accepts `scope` (singular) parameter per OAuth 2.0 spec; the `scopes` (plural) fallback has been removed (#106)
825
+
826
+ ## [0.5.2] - 2026-03-17
827
+
828
+ ### Added
829
+
830
+ - Configurable `sentry_context` lambda for host apps to supply extra Sentry user context fields (email, username, etc.) without overriding the concern method (#98)
831
+
832
+ ### Fixed
833
+
834
+ - Rescue `ArgumentError` in `skip_host_authorization` when controllers inherit ActionPolicy but haven't called `verify_authorized` (#98)
835
+ - Guard `sentry_context` lambda against nil returns, non-Hash returns, and non-callable config values (#98)
836
+ - Base Sentry context keys (`id`, `session_id`) cannot be overridden by the lambda (#98)
837
+
838
+ ## [0.5.1] - 2026-03-17
839
+
840
+ ### Fixed
841
+
842
+ - Use `skip_verify_authorized` for ActionPolicy framework in `skip_host_authorization`, with `respond_to?` guard for API controllers that don't include ActionPolicy (#96)
843
+ - Guard `SentryContext` against sessions without `id` method (#95)
844
+
845
+ ### Changed
846
+
847
+ - Bump production dependencies (#94)
848
+
849
+ ## [0.5.0] - 2026-03-13
850
+
851
+ ### Added
852
+
853
+ - Engine scope context to events for richer event payloads (#91)
854
+ - Nonce parameter support through authorization code flow (#89)
855
+
856
+ ### Changed
857
+
858
+ - Automate GitHub Releases from CHANGELOG.md on tag push (#90)
859
+
860
+ ## [0.4.0] - 2026-03-12
861
+
862
+ ### Added
863
+
864
+ - Controller auth-skip declarations and `StandardId.skip_host_authorization` for authorization gem integration (RAR-64) (#85)
865
+ - `StandardId::PasswordlessVerificationService` for custom passwordless login UIs (RAR-65) (#84)
866
+ - `StandardId::Testing` support package for host app test suites (RAR-66) (#74)
867
+
868
+ ### Fixed
869
+
870
+ - Thread-safe class-level memoization in `JwtService` and `PasswordFlow` (#87)
871
+
872
+ ## [0.3.2] - 2026-03-11
873
+
874
+ ### Added
875
+
876
+ - Optional `SentryContext` concern for enriching Sentry error reports with auth context (#76)
877
+ - Optional `current_user` alias for `current_account` (#75)
878
+ - `account_scope` configuration for eager-loading account associations (#77)
879
+
880
+ ### Fixed
881
+
882
+ - Normalize IPv6 localhost (`::1`) to `127.0.0.1` for consistent IP handling (#79)
883
+
884
+ ### Changed
885
+
886
+ - Add repo hygiene files: CONTRIBUTING, SECURITY, CODE_OF_CONDUCT (#78)
887
+
888
+ ## [0.3.1] - 2026-03-11
889
+
890
+ ### Added
891
+
892
+ - Bearer token extraction concern for flexible JWT authentication (RAR-48)
893
+ - JWT audience (`aud`) verification on token decode (RAR-48)
894
+ - Expired session cleanup job with configurable grace period (RAR-62)
895
+ - Boot-time warning when JWT issuer is not configured (RAR-54)
896
+
897
+ ### Fixed
898
+
899
+ - Social login now checks provider `email_verified` field before marking emails as verified (RAR-47)
900
+ - Prevent user enumeration via timing side-channel on password login with dummy bcrypt comparison (RAR-53)
901
+ - Replace database error leak in signup with generic message to prevent account enumeration (RAR-61)
902
+ - Remove `account` attribute from `AccountLockedError` to prevent sensitive data exposure (RAR-57)
903
+ - Add HTTP client timeouts (5s open, 10s read) to prevent resource exhaustion from slow OAuth providers (RAR-52)
904
+ - Cap token lifetimes at 24h (access) and 90d (refresh) with log warnings on clamping (RAR-55)
905
+
906
+ ## [0.3.0] - 2026-03-10
907
+
908
+ ### Added
909
+
910
+ - Passwordless OTP login flow for WebEngine (RAR-44)
911
+ - Audit logging documentation for integration with `standard_audit` gem
912
+
913
+ ### Fixed
914
+
915
+ - Disable `strict_loading` on `current_account` in session managers
916
+
917
+ ### Changed
918
+
919
+ - Upgrade to Ruby 4.0.1
920
+ - Standardize GitHub Actions workflows and lefthook git hooks
921
+ - Bump dependencies: sqlite3, puma, brakeman, rspec-rails, shoulda-matchers
922
+
923
+ ## [0.2.9] - 2025-12-02
924
+
925
+ ### Fixed
926
+
927
+ - Add `alg` and `use` fields to JWKS endpoint response
928
+
929
+ ## [0.2.8] - 2025-11-28
930
+
931
+ ### Added
932
+
933
+ - Signing key rotation with zero-downtime support (CORE-164)
934
+
935
+ ## [0.2.7] - 2025-11-20
936
+
937
+ ### Added
938
+
939
+ - Basic auth support for client secret authentication
940
+ - Redirect to `login_page` when not logged in
941
+
942
+ ## [0.2.6] - 2025-11-15
943
+
944
+ ### Added
945
+
946
+ - JWKS endpoint for JWT public key exposure (SWE-701)
947
+
948
+ ## [0.2.5] - 2025-11-10
949
+
950
+ ### Added
951
+
952
+ - Store `aud` on refresh tokens and expose via `current_session`
953
+
954
+ ## [0.2.4] - 2025-11-05
955
+
956
+ ### Added
957
+
958
+ - Refresh token support for social OAuth flow
959
+
960
+ ## [0.2.3] - 2025-10-30
961
+
962
+ ### Added
963
+
964
+ - Scope parameter support in social provider token exchange (SWE-697)
965
+
966
+ ## [0.2.2] - 2025-10-25
967
+
968
+ ### Added
969
+
970
+ - Action Cable authentication support
971
+
972
+ ## [0.2.1] - 2025-10-20
973
+
974
+ ### Added
975
+
976
+ - Login params support in OAuth sign-in flow
977
+
978
+ ## [0.2.0] - 2025-10-15
979
+
980
+ ### Added
981
+
982
+ - Account activation/deactivation with event-driven side effects
983
+ - Account locking/unlocking for administrative security
984
+ - Configurable session expiration
985
+ - Event-driven architecture replacing single callbacks
986
+
987
+ ### Changed
988
+
989
+ - Refactor social provider to prepare for plugin architecture
990
+ - Extract Apple and Google providers into separate gems (`standard_id-apple`, `standard_id-google`)
991
+ - Make gem thread-safe for multi-threaded servers
992
+ - Ensure event payloads are audit-ready for external subscribers
993
+
994
+ ## [0.1.7] - 2025-09-15
995
+
996
+ ### Added
997
+
998
+ - Event-driven architecture for extensibility and observability
999
+
1000
+ ## [0.1.6] - 2025-09-01
1001
+
1002
+ ### Added
1003
+
1004
+ - Inertia.js support for React/Vue/Svelte frontends
1005
+
1006
+ ## [0.1.5] - 2025-08-15
1007
+
1008
+ ### Added
1009
+
1010
+ - Apple Sign In integration
1011
+ - Social login callback support
1012
+ - Server-side authorization code flow for mobile
1013
+
1014
+ ### Fixed
1015
+
1016
+ - Social callback no longer always required
1017
+
1018
+ ## [0.1.4] - 2025-08-01
1019
+
1020
+ ### Added
1021
+
1022
+ - Google OAuth integration
1023
+ - Configurable custom scopes and claims
1024
+
1025
+ ## [0.1.3] - 2025-07-15
1026
+
1027
+ ### Added
1028
+
1029
+ - JWT scope validation in API authentication
1030
+ - Configurable OAuth token expiration
1031
+
1032
+ ## [0.1.2] - 2025-07-01
1033
+
1034
+ ### Fixed
1035
+
1036
+ - Client credential flow bugs
1037
+
1038
+ ## [0.1.1] - 2025-06-15
1039
+
1040
+ ### Changed
1041
+
1042
+ - Initial version bump after core setup
1043
+
1044
+ ## [0.1.0] - 2025-06-01
1045
+
1046
+ ### Added
1047
+
1048
+ - Core authentication engine with web and API dual-mount architecture
1049
+ - Cookie-based web sessions with CSRF protection
1050
+ - JWT-based API authentication
1051
+ - OAuth 2.0 authorization code flow with PKCE support
1052
+ - Implicit, client credentials, and password grant flows
1053
+ - Refresh token flow
1054
+ - Passwordless authentication via email/SMS OTP
1055
+ - STI-based session management (Browser, Device, Service)
1056
+ - STI-based identifiers (Email, Phone, Username)
1057
+ - Client application management with secret rotation
1058
+ - Configuration system with schema DSL
1059
+ - Install generator