feat(cli): wheels generate auth — session/token/jwt scaffold on the auth primitives - #3291
Conversation
Adds wheels.auth.PasswordHasher, the cross-engine password hashing service that unblocks the wheels generate auth scaffold (#3155, child of #2962). - PBKDF2-HMAC-SHA256 via javax.crypto.SecretKeyFactory (PBKDF2WithHmacSHA256) — byte-identical on Lucee, Adobe CF, and BoxLang by construction, so hashes survive engine migrations. - Defaults: 600000 iterations (OWASP 2023+), 16-byte SecureRandom salt, 256-bit derived key. - Self-describing modular-crypt storage format: $pbkdf2-sha256$i=<iterations>$<base64(salt)>$<base64(derivedKey)> - verify() re-derives with the stored salt/iterations and compares raw digest bytes in constant time (MessageDigest.isEqual); returns false, never throws, on malformed/empty/unknown-format hashes. - needsRehash() flags hashes below the configured iteration count or with an unrecognized format for transparent work-factor upgrades. - init() validates iterations as a positive integer and throws Wheels.PasswordHasher.InvalidConfiguration otherwise. - Unicode passwords round-trip (UTF-8); empty password hashing is allowed by design — minimum-length policy lives in app validations. - Not auto-registered in DI (matches Authenticator): the generator will wire it in config/services.cfm. TDD: 24-spec PasswordHasherSpec written first and confirmed failing for the right reason before implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
…uth primitives Implements #3155 (child of #2962): a one-command authentication scaffold built on the wheels.auth primitives (PasswordHasher, Authenticator, Session/Token/Jwt strategies). Dispatch: new 'auth' case in generate() -> generateAuth() in Module.cfc, orchestrated by Scaffold.generateAuth() with templates under cli/lucli/templates/auth/. Flags: --model=User (default), --strategy=session| token|jwt (default session), --registration/--no-registration (default on, session only), --force. Session strategy (default) emits a User model (PBKDF2 hashing via the passwordHasher service, transient password property validated then hashed and scrubbed in beforeSave, authenticate() with transparent rehash-on-login, single-use SHA-256-digested reset tokens expiring after 2h), Sessions/ Passwords/Registrations controllers (super.config() first line, private filters, all-named verifies, injection-safe query-builder finders), startFormTag-based views, a create-users migration with a unique email index, and generated app specs. Token/jwt emit app/controllers/api/Sessions.cfc instead (opaque digested bearer tokens with revocation, or JwtService-signed JWTs whose WHEELS_JWT_SECRET fails loudly at startup; no server-side revocation, documented in the generated header). Route, service, and strategy wiring are injected between // wheels:generate-auth:* markers in config/routes.cfm, config/services.cfm (created if absent), and app/events/onapplicationstart.cfm, always before root/wildcard, and replaced in place on --force — never duplicated. Generated code is code-you-own: stamped headers, re-run --force + git diff to upgrade. Migrations are never overwritten. Token validator and strategy constructors hoist closures (Cross-Engine Invariant 5). Tests: cli/lucli/tests/specs/services/GenerateAuthSpec.cfc (31 specs) covers dispatch, all three strategies' file sets, --no-registration, force/refuse semantics, marker idempotency, comment-stripped super.config() scans, and the hoisted-validator guard. Full CLI suite: 1122 pass, 0 fail, 0 error. Docs: generate-auth section in the CLI code-generation reference and a 'Scaffold it in one command' lead-in on the authentication-patterns guide. Closes #3155 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR adds wheels generate auth, a one-command session/token/jwt authentication scaffold built on the existing wheels.auth primitives. The implementation is careful and unusually well-tested: injection-safe finders, digested tokens, super.config()-first controllers, hoisted validator closures (Cross-Engine Invariant 5), marker-delimited idempotent config injection, and 31 CLI specs covering all three strategies plus force/idempotency. I verified every framework API the generated code leans on (QueryBuilder.first(), JwtService, Authenticator.hasStrategy/registerStrategy, env(), injector(), application.wheelsdi, $cgiScope) — they all exist and match. No blocking findings. Verdict: comment — one runtime-verification note and two minor observations below, none of which need to gate the merge. (Note: the PasswordHasher commit belongs to the stacked #3288 and will fall out of this diff once that merges — reviewed there, not here.)
Correctness
cli/lucli/templates/auth/controller-api-sessions-token.txt:43(token) DELETE revoke — verify the Authorization header actually reaches the strategy at runtime. The tokendelete()callsservice("authenticator").authenticate(request).TokenStrategy.$extractFromHeader(vendor/wheels/auth/TokenStrategy.cfc:148-164) reads the token fromrequest.headers.authorizationorrequest.cgi.http_authorization. But Wheels never setsrequest.headerson the request scope, and the per-requestrequest.cgiis built by$cgiScope()(vendor/wheels/Global.cfc:2470-2471) from a fixed key allowlist that does not includehttp_authorization. If that holds at runtime, the revoke endpoint (and any user filter that protects token/jwt actions the same way) can never see the bearer token and will always 401. This mirrors the documented guide pattern (digging-deeper/authentication-patterns.mdx:259,327,367), so it's plausibly a pre-existing framework/guide gap rather than something this PR introduced — and the PR body already notes the token/jwt runtime auth flows were not booted end-to-end. Flagging as please verify, not as a confirmed defect: exerciseDELETE /api/sessionwith a realAuthorization: Bearerheader against a booted app and confirm the token resolves.
Conventions
cli/lucli/services/Scaffold.cfc$injectAuthBlockroutes anchor —.end()fallback can land routes after the wildcard. First-time routes insertion prefers// CLI-Appends-Here, then an uncommented.root(, then falls back to inserting before the last.end(). The stockconfig/routes.cfmships// CLI-Appends-Herebefore.wildcard()/.root(), so the common path is correct (a test pinsbegin < .wildcard()). But in a hand-edited routes file with no anchor comment and a commented-out.root(, the.end()fallback would place the auth routes after.wildcard(), where they'd never match (anti-pattern #6). Low real-world likelihood, but consider emitting a manual-insert skip note instead of the.end()fallback, since that fallback is the one case that can produce wrong ordering.
Tests
- Strong coverage:
cli/lucli/tests/specs/services/GenerateAuthSpec.cfcexercises all three strategies,--no-registration, custom model name, force/refuse semantics, marker idempotency (exactly-once under re-run and--force), comment-strippedsuper.config()-first scans, and the hoisted-closure guard. Generated app specs correctly extendwheels.WheelsTest. No gaps worth blocking on. The token/jwt runtime auth round-trip (the Correctness note above) is the one behavior the suite can't reach from the CLI layer — an app-level spec that boots the API sessions controller would close that.
Docs
code-generation.mdxandauthentication-patterns.mdxupdated; changelog fragmentschangelog.d/3155-generate-auth.added.md(+3155-password-hasher.added.mdfor the stacked commit) follow the<slug>.<type>.mdconvention. Good.
Commits
- Both commits are commitlint-clean: valid types/scopes (
feat(cli),feat(auth)), subjects under 100 chars, DCO signed-off. Messages explain the "why." No issues.
Nice work — the marker-block injection design and the code-you-own stamping are exactly right, and the test suite is thorough. The single thing I'd genuinely want confirmed before shipping the token/jwt paths is the Authorization-header reachability in the Correctness section.
…reset hole Review hardening for the wheels generate auth scaffold (#3155): - BLOCKER: all three bootstrap templates emitted template-level `var` into app/events/onapplicationstart.cfm. That file is $include()d from a framework function and Adobe CF rejects top-level `var` at compile time (the #3063 class), so every generated app 500'd on every request on Adobe 2018-2025. All bootstrap variables are now `local.`-scoped (valid on every engine); the guide's four init-hook snippets were teaching the same pattern and are fixed too, with a caution aside. - HIGH: submitting the reset form with a blank password burned the token, reported success, and left the old password valid (presence is onCreate-only and the hash callback skips blanks). Passwords##update now rejects blanks with a field error before clearing the token. - Revoke endpoint could never see the bearer token: request.cgi's allowlist (Global.cfc $cgiScope) omits http_authorization and request.headers never exists, so authenticate(request) always 401'd. The token controller now hands the Authorization header to the authenticator explicitly. - Login timing oracle: unknown emails skipped the PBKDF2 derivation entirely, leaking account existence. All three login controllers now run a dummy derivation when the account is missing, and their headers plus the CLI next-steps recommend wheels.middleware.RateLimiter on credential endpoints. - Generated Sessions spec called processAction("create", params) — the only parameter is includeFilters, so "create" silently disabled before-filters. Now calls processAction() bare. - Routes injection: dropped the last-.end() fallback, which could park routes after .wildcard() where they never match (anti-pattern #6); with no safe anchor the generator now skips with a manual-insert note. Scaffold rollback now runs on any failure, not just typed ScaffoldErrors. - Passwords controller header documents that reset does not invalidate live sessions; next steps call out wiring reset-email delivery. - GenerateAuthSpec grows from 31 to 43 specs: local.-scope guards for all three bootstraps, blank-reset guard ordering, timing dummy, explicit-header revoke, processAction shape, and the three routes-anchor edge paths. CLI suite: 1134 pass / 0 fail / 0 error locally (Lucee 7 + SQLite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
Shepherd round 1 — review hardening pushed (2313810)Addressed the wheels-bot review plus an internal adversarial pass. CLI suite locally: 1134 pass / 0 fail / 0 error (GenerateAuthSpec 31 → 43 specs). Bot Correctness item (token revoke Authorization reachability) — verified, it was real. Bot Conventions item ( Internal review fixes in the same push:
|
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This is the review-hardening pass on wheels generate auth (the session/token/jwt scaffold). The new commit 2313810 cleanly resolves the single "please verify" item from the previous review (Authorization-header reachability on the token revoke endpoint) and self-hardens four more paths: Adobe-safe local.-scoped bootstraps, a blank-password reset hole, a login timing oracle, and the routes .end()-fallback ordering risk. I re-verified the framework APIs the changed code leans on against source. No blocking findings. Verdict: comment — the code paths are correct as written; the one residual is a runtime confirmation the CLI suite structurally cannot reach.
Correctness — prior findings, now resolved
-
Token revoke can now see the bearer token. The previous review flagged that
service("authenticator").authenticate(request)could not reach theAuthorizationheader becauserequest.cgi's allowlist (Global.cfc $cgiScope) omitshttp_authorizationandrequest.headersis never set.controller-api-sessions-token.txt:59-62now reads the header viaGetHttpRequestData(false).headersand hands it to the authenticator as{cgi: {http_authorization: ...}}. I traced this end-to-end:TokenStrategy.$extractFromHeader(vendor/wheels/auth/TokenStrategy.cfc:159-163) readsrequest.cgi["http_" & Replace(headerName,"-","_","ALL")]=http_authorization, strips theBearerscheme (:171-179, default scheme"Bearer", headerName lowercased to"authorization"), andAuthenticator.$authenticate(:135,:150-151) tries everysupports()-true strategy with no filter. The mechanism is correct.- Residual (please verify, not blocking): the token/jwt paths still are not booted end-to-end (PR body acknowledges this). Worth one manual
DELETE /api/sessionwith a realAuthorization: Bearerheader against a running app to confirm the round-trip — the CLI spec layer cannot exercise it.
- Residual (please verify, not blocking): the token/jwt paths still are not booted end-to-end (PR body acknowledges this). Worth one manual
-
Blank-password reset hole closed.
controller-passwords.txt:75-79now rejects a blank password withaddError()+renderView("edit")before clearingresetTokenDigest. Correct: presence iswhen="onCreate"(model.txt:24) andhashPasswordPropertyskips blanks (model.txt:75), so without this guard a blank submit would burn the token yet leave the old hash intact. The token is only zeroed at:81-82after the guard, and since it is set on the in-memory object and persisted by the samesave()(:83), a later validation failure (e.g.validatesConfirmationOf) leaves the DB token intact. Sound.
Cross-engine
- Adobe top-level
varfixed. All three bootstrap templates (bootstrap-session.txt,bootstrap-token.txt,bootstrap-jwt.txt) now uselocal.-scoped variables, with an inline note tying it to #3063. Correct — these files are$include()d from inside a framework function, solocalresolves to that function's scope on every engine while template-levelvarcompile-fails on Adobe. The validator closure stays hoisted intolocal.tokenValidatorbefore thenew wheels.auth.TokenStrategy(...)named-arg call (bootstrap-token.txt:13-22), honoring Cross-Engine Invariant #5. The four guide init-hook snippets got the same treatment plus a caution<Aside>— consistent.
Conventions
- Routes
.end()fallback removed.Scaffold.cfc:982-991drops the last-.end()insertion fallback that could park auth routes after.wildcard()(anti-pattern #6); with no// CLI-Appends-Hereor uncommented.root(anchor the generator now skips with a manual-insert note instead of injecting dead routes.$findCodePosition(:1021-1032) correctly ignores commented-out.root(lines. Minor: it strips//line comments only, not/* */block comments — a.root(inside a block comment could still false-match — but that is a pre-existing helper and a negligible edge for an app-owned routes file.
Tests
GenerateAuthSpec.cfcgrows 31 to 43 specs covering the new behavior:local.-scope guards for all three bootstraps, blank-reset ordering, the timing dummy, explicit-header revoke, theprocessAction()shape fix, and the three routes-anchor edge paths. The generatedspec-sessions-controller.txt:43fix (processAction()bare, notprocessAction("create", loginParams)) is right —processAction's only param isincludeFilters, so the old call was silently disabling before-filters. Generated specs extendwheels.WheelsTest. Good coverage for the CLI layer.
Commits
fix(cli): make generate auth Adobe-safe and close the blank-password reset hole— commitlint-clean (fixtype,cliscope, header under 100, DCO signed-off), and the body explains the "why" for each change. No issues.
Careful, well-tested hardening pass. Everything I could verify against source checks out; the only open item is booting the token/jwt API paths once against a live app, which is a confirmation step rather than a defect.
…amespaced extends Booting generated apps end-to-end (the runtime confirmation wheels-bot asked for on all three strategies) surfaced two defects that string-level CLI specs cannot reach: 1. Every new-account creation failed validation. passwordDigest is an allowNull=false column, so Wheels auto-registers validatesPresenceOf for it — but the generated model only populates the digest in the beforeSave callback, which runs AFTER validation. Registration, programmatic creation, seeds, and the generated UserAuthSpec all failed with 'Password Digest can't be empty' (the exact trap the authentication guide documents at its beforeValidation stopgap). Fixed with property(name="passwordDigest", automaticValidations=false) — presence stays guaranteed by validatesPresenceOf(password) on create feeding the hashing callback, with the NOT NULL constraint as backstop. This keeps the validate-plaintext-then-hash ordering. 2. The token/jwt controllers live in app/controllers/api/ but extended the bare 'Controller', which cannot resolve from a subfolder — every request 500'd with 'invalid component definition, can't find component [Controller]'. Now extends app.controllers.Controller, matching the admin generator's namespaced-controller convention. Verified live against three freshly scaffolded apps (Lucee 7 + SQLite): token (201+token, 401 wrong/unknown, Bearer revoke 200 then 401), jwt (valid HS256 with sub/email/iat/exp, 401, stateless delete note), session (CSRF register 303+flash, wrong-password re-render, login 303). Three new specs pin both fixes. CLI suite: 1137 pass / 0 fail / 0 error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
Round 2 — runtime verification done (the bot's residual ask), two real defects found and fixedCommit Defect 1 — every new-account creation failed validation (all three strategies)
Fix: Defect 2 — token/jwt controllers 500'd on every request
Verified live (after fixes, from regenerated templates — no hand patching)
Also verified along the way: TestsThree new specs pin the fixes (digest auto-validation opt-out asserted for all three strategy fixtures; |
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This is the make-it-bootable pass on wheels generate auth. The new head commit 2be96af resolves the exact residual the two prior reviews left open (the token/jwt paths were never booted end-to-end) by actually running all three strategies against freshly scaffolded Lucee 7 + SQLite apps, and fixes the two defects that boot surfaced: (1) passwordDigest is populated in beforeSave after validation, so Wheels' auto NOT-NULL presence validation on the allowNull=false column rejected every new record — now suppressed with property(name="passwordDigest", automaticValidations=false); (2) the token/jwt controllers live in app/controllers/api/ but extended a bare Controller, which cannot resolve from a subfolder — now extends="app.controllers.Controller". I verified both fixes against framework source. No blocking findings. Verdict: comment — the delta is clean; the only non-code note is the stacked-PR merge ordering.
Correctness — both fixes verified against source
-
model.txt:25 —
automaticValidations=falsecorrectly suppresses the auto presence validation without opening a hole. The per-property gate is real:Model.cfc:232-238skips automatic validation registration when the per-property automaticValidations flag on the class mapping isfalse, andproperty()(vendor/wheels/model/properties.cfc:119-121) sets exactly that key. Presence is still guaranteed for the create path —model.txt:32keepsvalidatesPresenceOf(property="password", when="onCreate"), which feedshashPasswordProperty(:82-89), with the DB allowNull=false (migration.txt:15) as backstop. Theconfig()-time ordering is proven by the existing framework assetvendor/wheels/tests/_assets/models/UserAutomaticValidationsOff.cfc:6(property(name="id", automaticValidations=false)). No length concern from losing the auto max-length check either:passwordDigestis limit=500 (migration.txt:15), well above any PBKDF2 digest. -
API session controllers (jwt and token) — namespaced
extendsmatches the admin generator convention. The API controllers land inapp/controllers/api/, and the fix toextends="app.controllers.Controller"mirrorscli/lucli/templates/admin/controller.txt:1, which already usesextends="app.controllers.Controller"for its own subfolder controller. The baseapp/controllers/Controller.cfcextendswheels.Controller, so the mapping path resolves. The top-level session controllers keepextends="Controller"(correct — they resolve relative toapp/controllers/).
Tests
- Three new specs in
GenerateAuthSpec.cfcpin both fixes: theautomaticValidations=falseline across all three strategies (:168-179) and the namespacedextendsfor token (:349-357) and jwt (:399-402). These are string-level assertions, which is the ceiling of the CLI spec layer — the actual boot round-trip is covered by the commit's documented live verification (token 201/401/revoke, jwt HS256, session CSRF 303). Appropriate coverage for the layer.
Commits
fix(cli): make generated auth apps bootable — digest validation and namespaced extends— commitlint-clean (fixtype,cliscope, header under 100, DCO signed-off), and the body explains the "why" for both defects with the runtime evidence. No issues.
Non-blocking note (already stated in the PR body, repeating for the merge checklist): this branch is stacked on #3288 and still carries the PasswordHasher commit + spec in the diff — merge #3288 first so that commit falls out cleanly. That code was reviewed in #3288, not here.
Nice work closing the loop with a real boot — that's precisely the confirmation the previous two passes asked for.
Closes #3155. Part of #2962.
Implements
wheels generate auth— a one-command authentication scaffold built on the existingwheels.authprimitives (PasswordHasher,Authenticator,SessionStrategy/TokenStrategy/JwtStrategy), following the Phoenix/Rails-8 shape recommended by the wheels-bot cross-framework research on the issue.Approved design decisions
The maintainer signed off on 2026-07-06 on the design calls flagged in the issue and the bot's research comment:
--strategy=session).--strategy=tokenand--strategy=jwtAPI variants included in v1 — both emitapp/controllers/api/Sessions.cfc(no views).--no-registration(session strategy only; noted as not-applicable for token/jwt).SessionStrategy— no DB sessions table.--forceand reviewinggit diff. Marker-delimited config blocks are replaced in place, never duplicated. Migrations are never overwritten.PasswordHasherservice (from feat(auth): PBKDF2 password hashing service (PasswordHasher) #3288) — resolves the cross-engine bcrypt gap the bot's fix-hold flagged.What it generates
Session (default):
app/models/User.cfc— transientpasswordproperty validated (presence on create, unique+format email, confirmation, 12-char minimum), hashed intopasswordDigestinbeforeSaveand scrubbed;authenticate()with transparent rehash-on-login; single-use SHA-256-digested reset tokens (2h expiry, burned on use);protectedProperties()guards mass assignment.app/controllers/Sessions.cfc,Passwords.cfc,Registrations.cfc— everyconfig()callssuper.config()first (the CSRF default-on decision (5.0): flip the default after the detect-and-warn deprecation window #2960 CSRF footgun), all-namedverifies(...), injection-safe query-builder finders.startFormTag-based views (CSRF token automatic),cfparam'd, plain semantic HTML.app/migrator/migrations/<ts>_create_users_table.cfc—t.string(columnNames=...)style,timestamps(), unique email index.// wheels:generate-auth:*) injected intoconfig/routes.cfm(before root/wildcard),config/services.cfm(created if absent —passwordHasher/authenticator/sessionStrategysingletons), andapp/events/onapplicationstart.cfm(strategy registration, per the documented wiring).tests/specs/models/UserAuthSpec.cfc,tests/specs/controllers/SessionsControllerSpec.cfc.Token: same model + migration plus
apiTokenDigest;api/Sessions.cfcmints an opaque token (plaintext returned once, only the SHA-256 digest stored),DELETErevokes;TokenStrategywired with a hoisted validator closure (Cross-Engine Invariant 5).JWT:
api/Sessions.cfcmints JWTs viaJwtService;WHEELS_JWT_SECRETread from the environment and startup fails loudly when missing/shorter than 32 bytes; no-server-side-revocation documented in the generated header.Tests
cli/lucli/tests/specs/services/GenerateAuthSpec.cfc(31 specs): dispatch reachesgenerateAuthvia thegenerate()switch, per-strategy file sets,--no-registrationomissions, force-overwrites/refuses-without-force, marker idempotency across re-runs, comment-strippedsuper.config()-first scans of every emitted controller, and a no-inline-closure-as-constructor-arg guard./wheels/cli/tests?format=json): 1122 pass, 0 fail, 0 error.--no-registration) into temp project dirs through the dev server and eyeballing the emitted files and injected blocks (routes land before.wildcard()/.root(), services/bootstrap blocks correctly wrapped). A fullwheels newboot test was not run.Docs
wheels generate authsection + type-table row in the CLI code-generation reference.digging-deeper/authentication-patterns.mdx.changelog.d/3155-generate-auth.added.md.🤖 Generated with Claude Code