From 95a374fad15073c1d5dc553a316e3e6fbe5eb99c Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 22 May 2026 12:25:59 -0500 Subject: [PATCH 1/3] fix(jwt): fall back to app->kyte_account for app-scoped users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JwtEndpoint::login required user->kyte_account != 0 to proceed. That holds for the default KyteUser model but NOT for app-scoped User models created via Shipyard DataModel — those users belong to the Application, and the Application carries the kyte_account FK. Result: login on apps with custom user_model returned 401 even for valid credentials. STEP3 (accountId === 0) bailed before token issuance, despite STEP1 user retrieve + STEP2 password_verify both passing. Fix: if user->kyte_account is missing/0 and app context is set, fall back to app->kyte_account. Mirrors how HMAC derives account context from the app for session-on-app flows. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Core/Auth/JwtEndpoint.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php index ece6005..546946e 100644 --- a/src/Core/Auth/JwtEndpoint.php +++ b/src/Core/Auth/JwtEndpoint.php @@ -153,11 +153,24 @@ private static function login(array $body, string $ip): array return self::error(401, 'invalid_credentials', 'Invalid credentials.'); } + // Resolve the user's account. KyteUser has a direct kyte_account FK, + // but app-scoped User models (created via DataModel in Shipyard) do + // not — the user belongs to the Application, and the Application + // belongs to the account. Fall back to $app->kyte_account in that + // case. This mirrors how HMAC's account context is derived: the + // session is on the app, and the app carries the account FK. $accountId = isset($user->kyte_account) ? (int)$user->kyte_account : 0; + if ($accountId === 0 && $app !== null && isset($app->kyte_account)) { + $accountId = (int)$app->kyte_account; + } if ($accountId === 0) { return self::error(401, 'invalid_credentials', 'Invalid credentials.'); } + // KyteAccount lives in the default (system) DB. After the app-scoped + // user retrieve above, dbswitch is set to App DB. ModelObject->retrieve + // toggles back to default automatically when the target model has no + // 'appId' (KyteAccount doesn't), so no explicit dbswitch needed here. $account = new ModelObject(KyteAccount); if (!$account->retrieve('id', $accountId)) { return self::error(401, 'invalid_credentials', 'Invalid credentials.'); From 1445ca8a58a4d9e00858b306bcf620d4555930c5 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 22 May 2026 12:29:28 -0500 Subject: [PATCH 2/3] feat(jwt): mirror HMAC session response shape (uid, account_id, data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT /jwt/login was returning only the tokens. HMAC /Session also returns uid, account_id, and data (user object array with protected fields stripped) — customer apps consuming the response (e.g. session.data[0].organization_type) silently fail under JWT because the payload was missing. Add to JWT login response: - uid: user.id - account_id: account.id - data: user data with protected fields ('' replaced), wrapped per USE_SESSION_MAP (array vs object) to match HMAC SessionController::new behavior Intentionally NOT included: - kyte_pub / kyte_iden / kyte_num — HMAC-only API handoff creds (JWT has no handoff concept; access_token is the credential) - session / token — HMAC-only sessionToken / txToken - session_created — derivable from access token iat claim New userToArray() helper standalone-replicates the protected-field stripping from ModelController::getObject without requiring a controller context. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Core/Auth/JwtEndpoint.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php index 546946e..37fb2a5 100644 --- a/src/Core/Auth/JwtEndpoint.php +++ b/src/Core/Auth/JwtEndpoint.php @@ -190,15 +190,47 @@ private static function login(array $body, string $ip): array } } + // Mirror HMAC SessionController::new response shape so apps that + // consumed the HMAC session response (data[0], uid, account_id) + // keep working under JWT without code changes. The HMAC-only + // fields (kyte_pub, kyte_iden, kyte_num, session, token) are + // intentionally omitted — JWT doesn't have an API-handoff cred + // model, and there's no sessionToken/txToken concept. + $userData = self::userToArray($user); + $useSessionMap = defined('USE_SESSION_MAP') && USE_SESSION_MAP; + return self::success([ 'access_token' => $accessToken, 'token_type' => 'Bearer', 'expires_in' => self::accessTtl(), 'refresh_token' => $refresh['raw'], 'refresh_expires_at' => $refresh['expires_at'], + 'uid' => (int)$user->id, + 'account_id' => (int)$account->id, + 'data' => $useSessionMap ? $userData : [$userData], ]); } + /** + * Serialize a ModelObject for the JWT login response. Strips fields + * marked `protected: true` in the model struct (e.g. password hash, + * secret_key) — mirrors what ModelController::getObject does but + * standalone, since we can't invoke a controller from here. + */ + private static function userToArray(ModelObject $user): array + { + $params = $user->getAllParams(); + $struct = $user->kyte_model['struct'] ?? []; + foreach ($params as $key => $value) { + if (isset($struct[$key]['protected']) && $struct[$key]['protected']) { + $params[$key] = ''; // strip protected fields (password hash, etc.) + } + } + // The kyte_model handle itself should never leak to clients. + unset($params['kyte_model']); + return $params; + } + private static function refresh(array $body, string $ip): array { $raw = (string)($body['refresh_token'] ?? ''); From b00684c4e86a5a95929f92e07a63e034fbc2e0bf Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 22 May 2026 12:34:22 -0500 Subject: [PATCH 3/3] feat(jwt): expand FK fields in login response data (SESSION_RETURN_FK parity) HMAC SessionController sets getFKTables = SESSION_RETURN_FK (default true), causing getObject() to recursively expand FK integers into full nested objects. JWT userToArray was returning raw FK ints, so frontend code like 'user.org.org_type' silently failed (org was '2', not {id:2, org_type:'LP'}). Add FK expansion to userToArray: - Walk model struct - For fields with 'fk' definition + non-empty value, retrieve the referenced ModelObject and recursively serialize it - Bounded at depth 3 to prevent runaway recursion on cyclic FKs Respects SESSION_RETURN_FK constant (defaults true, can be disabled per-deployment). retrieve() handles dbswitch automatically based on the FK model's appId, so app-scoped + default-scoped FKs both work. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Core/Auth/JwtEndpoint.php | 43 ++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php index 37fb2a5..bdde6fe 100644 --- a/src/Core/Auth/JwtEndpoint.php +++ b/src/Core/Auth/JwtEndpoint.php @@ -212,20 +212,51 @@ private static function login(array $body, string $ip): array } /** - * Serialize a ModelObject for the JWT login response. Strips fields - * marked `protected: true` in the model struct (e.g. password hash, - * secret_key) — mirrors what ModelController::getObject does but - * standalone, since we can't invoke a controller from here. + * Serialize a ModelObject for the JWT login response. Mirrors the + * subset of ModelController::getObject that the customer app actually + * consumes: + * - Strip fields marked `protected: true` (password hash, etc.) + * - Expand FK references into nested objects when SESSION_RETURN_FK + * is enabled (default true). Without this, `user.org` would be + * the integer `2` instead of `{id:2, org_type:'LP', ...}` — and + * frontends doing `user.org.org_type` would silently break. + * + * Recursion-bounded at depth 3 to prevent runaway expansion on + * cyclic FKs (rare but possible in customer schemas). */ - private static function userToArray(ModelObject $user): array + private static function userToArray(ModelObject $user, int $depth = 0): array { $params = $user->getAllParams(); $struct = $user->kyte_model['struct'] ?? []; + $expandFks = $depth < 3 + && (!defined('SESSION_RETURN_FK') || SESSION_RETURN_FK); + foreach ($params as $key => $value) { + if (!isset($struct[$key])) { + continue; + } + if (isset($struct[$key]['protected']) && $struct[$key]['protected']) { - $params[$key] = ''; // strip protected fields (password hash, etc.) + $params[$key] = ''; + continue; + } + + // Expand FK if struct declares one, value is non-empty, and + // SESSION_RETURN_FK allows. + if ($expandFks && isset($struct[$key]['fk'], $value) && !empty($value)) { + $fk = $struct[$key]['fk']; + if (isset($fk['model'], $fk['field']) && defined($fk['model'])) { + $fkModel = constant($fk['model']); + $fkObj = new ModelObject($fkModel); + // retrieve() handles dbswitch automatically based on + // whether the FK model has 'appId' (app-scoped vs default). + if ($fkObj->retrieve($fk['field'], $value, null, null, true)) { + $params[$key] = self::userToArray($fkObj, $depth + 1); + } + } } } + // The kyte_model handle itself should never leak to clients. unset($params['kyte_model']); return $params;