From 0e7cb000a3ca667568914da65cce16ed94123b18 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 22 May 2026 12:07:26 -0500 Subject: [PATCH] fix(jwt): resolveAuthContext must load app models before constant() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an Application defines a custom user_model (e.g. user_model='User'), JwtEndpoint::resolveAuthContext called constant($app->user_model) to resolve the model definition. App-scoped constants are registered by Api::loadAppModels() during the normal MVC pipeline — but JwtEndpoint dispatches *before* that pipeline runs (login can't require auth). Result: PHP 8 fatal "Undefined constant 'User'" → HTTP 500 from /jwt/login on every app with a custom user_model. HMAC /Session login didn't hit this because Api::route() walks through loadAppModels() before reaching the session controller. The JWT endpoint deliberately bypasses that path, creating the gap. Fix: call Api::loadAppModels($app) right after retrieving the app row and before referencing the constant. Defensive fallback: if the constant still isn't defined after loading (e.g. user_model references a name that no DataModel row defines), fall back to KyteUser with an error_log breadcrumb. Login then fails at password_verify rather than fataling — safer client-facing surface. All 12 JwtEndpointTest tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++++++++++++ src/Core/Auth/JwtEndpoint.php | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da92acf..14af973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 4.4.3 + +### Bug Fix: `/jwt/login` fatals on apps with custom `user_model` + +When an Application's `user_model` is set to an app-specific DataModel (e.g. `"User"` rather than the default `"KyteUser"`), `JwtEndpoint::resolveAuthContext` called `constant($app->user_model)` to resolve the model definition. But the JWT endpoint dispatches *before* `Api::loadAppModels()` runs in the normal MVC pipeline, so the app-scoped constant wasn't yet defined. In PHP 8+ that's a fatal: `Undefined constant "User"`. Surface to the client was HTTP 500. + +HMAC `/Session` login did not hit this because `Api::route()` calls `loadAppModels` before reaching the session controller. JWT's whole point is to bypass that pipeline (login can't require auth), which is what created the gap. + +Fix: `resolveAuthContext` now calls `Api::loadAppModels($app)` immediately after retrieving the Application, before referencing the constant. If the app references a name that no DataModel row defines, falls back to `KyteUser` (with an `error_log` breadcrumb) rather than fatal — login then naturally fails at `password_verify`, which is a safer surface than a 500. + +No schema changes. Composer upgrade is sufficient. + ## 4.4.2 ### Bug Fix: ErrorHandler crash when `apiContext->key` is a ModelObject diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php index a10aa9d..3faf3db 100644 --- a/src/Core/Auth/JwtEndpoint.php +++ b/src/Core/Auth/JwtEndpoint.php @@ -335,6 +335,27 @@ private static function resolveAuthContext(?string $appIdentifier): array } if ($app->user_model !== null && $app->username_colname !== null && $app->password_colname !== null) { + // App-specific DataModel constants ("User", "Customer", etc.) are + // registered lazily by Api::loadAppModels when an app context is + // detected in the normal MVC pipeline. JwtEndpoint dispatches + // BEFORE that pipeline runs, so the constant we want may not + // exist yet. Load explicitly to make the constant available. + \Kyte\Core\Api::loadAppModels($app); + + if (!defined($app->user_model)) { + // loadAppModels couldn't define it (no matching DataModel + // row, or the row references a name that wasn't registered). + // Fall back to KyteUser rather than fatal — login will then + // fail at password_verify, which is the safer surface. + error_log("JwtEndpoint: app '{$appIdentifier}' references user_model '{$app->user_model}' but no DataModel row defines it; falling back to KyteUser."); + return [ + 'user_model' => KyteUser, + 'username_field' => $defaultUserField, + 'password_field' => $defaultPassField, + 'app' => $app, + ]; + } + return [ 'user_model' => constant($app->user_model), 'username_field' => (string)$app->username_colname,