Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
54 changes: 54 additions & 0 deletions migrations/4.5.3_mcp_tokens.sql
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 25 additions & 10 deletions src/Core/Auth/JwtEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading