diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ef902..9883a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 4.5.3 + +### Bug Fix (critical): `/jwt/refresh` 401s for apps with a custom `user_model` + +`JwtEndpoint::refresh()` reloaded the principal with a hardcoded `new ModelObject(KyteUser)`. Apps that authenticate against an app-scoped `user_model` (a `User` DataModel, not `KyteUser`) store the user in the app DB, so `KyteUser->retrieve('id', $userId)` finds nothing → `401 invalid_credentials "Refresh token principal not found."` + +Impact: every JWT session on such an app dies on its **first refresh** — i.e. ~15 minutes (the access-token TTL) after login, presenting as "JWT randomly fails after a few minutes of inactivity." `login()` already handled custom user models (v4.4.3–4.4.5 via `resolveAuthContext`), but `refresh()` was never given the same treatment, so the bug was latent until an app with a custom user model was migrated HMAC→JWT. + +Fix: `refresh()` now resolves the app from `refresh_token.app_id`, calls `resolveAuthContext($appIdentifier)` (which loads the app models + DB context and returns the correct `user_model`), and retrieves the principal from that model — mirroring `login()`. Default `KyteUser` path (no app scope) is unchanged. + +No schema change. Composer upgrade is sufficient. Strongly recommended for any deployment running JWT on apps with custom user models. + +### Migration backfill: MCP token table + +Adds `migrations/4.5.3_mcp_tokens.sql` — creates `KyteMCPToken` (consumed by `McpTokenStrategy` / the Shipyard Tokens page). The Phase 2 MCP code shipped without its table-creation migration; this closes that gap. `CREATE TABLE IF NOT EXISTS`, safe to re-run. + ## 4.5.2 ### Bug Fix: `sensitive` toggle on a Controller silently fails (TypeError on metadata-only PUT) diff --git a/migrations/4.5.3_mcp_tokens.sql b/migrations/4.5.3_mcp_tokens.sql new file mode 100644 index 0000000..743b7f0 --- /dev/null +++ b/migrations/4.5.3_mcp_tokens.sql @@ -0,0 +1,54 @@ +-- ========================================================================= +-- Kyte v4.5.3 - MCP token storage (Phase 2 backfill) +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Creates the KyteMCPToken table consumed by the MCP auth strategy +-- (McpTokenStrategy) and issued via the Shipyard Tokens page. Opaque +-- bearer tokens (prefix `kmcp_live_...`) authenticate Claude Code / +-- Claude.ai to the per-tenant /mcp endpoint. Scope-gated (read/draft/ +-- commit), revokable, IP-restrictable, TTL'd. Only the sha256 hash is +-- stored; the raw token is shown once at creation. +-- +-- Backfill note: the KyteMCPToken model + /mcp server + McpTokenStrategy +-- shipped in the Phase 2 source merge, but the table-creation migration +-- was never committed (the model file notes "index creation happens in +-- the Phase 2 migration"). This file closes that gap. Existing installs +-- that already ran an equivalent CREATE are unaffected (IF NOT EXISTS). +-- +-- See src/Mvc/Model/KyteMCPToken.php for the model spec and +-- docs/design/kyte-mcp-and-auth-migration.md section 5.4. +-- ========================================================================= + +CREATE TABLE IF NOT EXISTS `KyteMCPToken` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + `token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 hex of the raw MCP token', + `token_prefix` VARCHAR(16) NOT NULL COMMENT 'First chars of the raw token (kmcp_live_...) for UI identification', + `name` VARCHAR(255) NOT NULL COMMENT 'Human-facing label', + + `application` BIGINT UNSIGNED DEFAULT NULL COMMENT 'App scope; NULL = account-wide (reserved)', + `scopes` VARCHAR(255) NOT NULL COMMENT 'CSV of read|draft|commit', + + `expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Unix epoch; 0 = never (discouraged)', + `last_used_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `last_used_ip` VARCHAR(45) DEFAULT NULL, + `ip_allowlist` TEXT DEFAULT NULL COMMENT 'Optional CSV CIDR allowlist; empty = any', + + `revoked_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 = active, nonzero = revoked', + + `kyte_account` BIGINT UNSIGNED NOT NULL, + + `created_by` BIGINT UNSIGNED DEFAULT NULL, + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + + UNIQUE KEY `idx_token_hash` (`token_hash`), + KEY `idx_application` (`application`), + KEY `idx_account` (`kyte_account`), + KEY `idx_account_revoked` (`kyte_account`, `revoked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php index bdde6fe..b5894e8 100644 --- a/src/Core/Auth/JwtEndpoint.php +++ b/src/Core/Auth/JwtEndpoint.php @@ -271,27 +271,42 @@ private static function refresh(array $body, string $ip): array $result = RefreshTokenStore::rotate($raw, $ip); - // Re-load user + account for the new access token. - $user = new ModelObject(KyteUser); + // Resolve the user from the SAME model the app authenticates against. + // Apps with a custom user_model (an app-scoped User DataModel) do NOT + // live in KyteUser — login() handles this via resolveAuthContext(), and + // refresh() MUST mirror it. Without this, every refresh on such an app + // 401s with "principal not found" the moment the 15-min access token + // expires (the user_id is an app-DB id, absent from KyteUser). Resolve + // the app first so resolveAuthContext loads the app models + DB context + // and hands back the correct user_model. + $appIdentifier = null; + $userModel = KyteUser; + if ($result['app_id'] !== null) { + $app = new ModelObject(Application); + if ($app->retrieve('id', $result['app_id'])) { + $appIdentifier = (string)$app->identifier; + $context = self::resolveAuthContext($appIdentifier); + $userModel = $context['user_model']; + } + } + + // Re-load user for the new access token from the resolved model. + $user = new ModelObject($userModel); if (!$user->retrieve('id', $result['user_id'])) { // The user's row was removed between issuance and refresh. // Family revocation is appropriate — the principal is gone. return self::error(401, 'invalid_credentials', 'Refresh token principal not found.'); } + // KyteAccount lives in the default (system) DB. resolveAuthContext may + // have set the app DB credentials; ModelObject->retrieve toggles back to + // the default DB automatically for models without 'appId' (KyteAccount), + // so no explicit dbswitch is needed here. $account = new ModelObject(KyteAccount); if (!$account->retrieve('id', $result['account_id'])) { return self::error(401, 'invalid_credentials', 'Refresh token account not found.'); } - $appIdentifier = null; - if ($result['app_id'] !== null) { - $app = new ModelObject(Application); - if ($app->retrieve('id', $result['app_id'])) { - $appIdentifier = (string)$app->identifier; - } - } - $accessToken = self::mintAccessJwt($user, $account, $appIdentifier); return self::success([