Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2725b7a
feat(551): OAuth AS foundation — discovery, storage models, WWW-Authe…
kennethphough Aug 1, 2026
e06b8d0
feat(551): P1-B — OAuth dynamic client registration (RFC 7591)
kennethphough Aug 1, 2026
238b965
feat(551): P1-C backend — authorize redirect + authed consent endpoints
kennethphough Aug 1, 2026
d74b86b
feat(551): P1-D — OAuth token endpoint (mint kmcp_live_ via code+PKCE)
kennethphough Aug 1, 2026
ad6a6f9
harden(551): drop Allow-Credentials from OAuth CORS (security review)
kennethphough Aug 1, 2026
d77e410
chore(551): point authorization_endpoint at /oauth-authorize.html (root)
kennethphough Aug 2, 2026
460829d
feat(343): MCP create/update/delete controller tools
kennethphough Aug 2, 2026
4888163
feat(345): MCP media tools (list / read / create / delete)
kennethphough Aug 2, 2026
cfe604f
fix(345): guard mediaToArray with isset (undefined-property warning o…
kennethphough Aug 2, 2026
14d7ce1
feat: MCP application tools + remove dead app logs-bucket
kennethphough Aug 2, 2026
6e8d857
feat: separate privileged DB connection for provisioning (create/drop…
kennethphough Aug 2, 2026
461f572
fix: grant explicit tenant privileges (GRANT ALL denied to provisioni…
kennethphough Aug 2, 2026
075acea
feat(559): async application teardown (stop orphaning site AWS infra)
kennethphough Aug 2, 2026
481286d
feat(560): app SSO P1 foundation — OIDC config models + /sso/authorize
kennethphough Aug 2, 2026
5834356
feat(560): app SSO P1 callback — token exchange + id_token validation…
kennethphough Aug 2, 2026
a7e0e1b
fix(560): pass RS256 default to JWK::parseKeySet (Azure JWKS omits pe…
kennethphough Aug 2, 2026
56a3c8b
fix(560): validate SSO return_url against the app's own site domains
kennethphough Aug 2, 2026
ee43be8
fix(560): harden SSO identity — subject binding, verified-email trust…
kennethphough Aug 2, 2026
8e8579c
fix(560): close SSO email-link residuals — guest exclusion, no re-bin…
kennethphough Aug 2, 2026
65be96b
chore(560): remove now-dead notImplemented() helper (callback/exchang…
kennethphough Aug 2, 2026
bb71a33
merge(feature/345-mcp-media-tools): into integration/mcp-oauth-sso
kennethphough Aug 2, 2026
29a7fc1
merge(feature/mcp-app-tools): into integration/mcp-oauth-sso
kennethphough Aug 2, 2026
11ffd3b
merge(oauth-as #551): into integration
kennethphough Aug 2, 2026
8773d39
merge(app-sso #560): into integration
kennethphough Aug 2, 2026
271b9cc
feat(mcp): add create_function tool (hooks / method overrides / custom)
kennethphough Aug 2, 2026
e160d89
fix(functions): store 'initial' baseline version as a valid version_type
kennethphough Aug 2, 2026
ad4a030
feat(mcp): add create_page tool (QA blocker — UI phase)
kennethphough Aug 2, 2026
c900ea2
feat(mcp): add get_app_info tool (connection/endpoint discovery)
kennethphough Aug 2, 2026
43dee5d
feat(mcp): script CRUD tools + KyteJS guidance (get_kytejs_guide + se…
kennethphough Aug 2, 2026
ec9f81c
feat(mcp): auto-generate kyte_connect on create_application + get_con…
kennethphough Aug 3, 2026
966c7ff
feat(mcp): finish CRUD parity — read_application, delete_function, de…
kennethphough Aug 3, 2026
0ed5643
fix(mcp): correct the authoring guides against the actual SDK + contr…
kennethphough Aug 3, 2026
2253aa8
fix(controller): init $order before hook_prequery in update(); anti-d…
kennethphough Aug 3, 2026
9c67bfd
feat(mcp): expose auth config — attribute password/protected/sensitiv…
kennethphough Aug 3, 2026
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
81 changes: 81 additions & 0 deletions migrations/4.17.0_oauth_as.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- =========================================================================
-- Kyte v4.17.0 - OAuth 2.1 Authorization Server for the hosted MCP connector
-- =========================================================================
-- IMPORTANT: Backup your database before running this migration.
--
-- KYTE-#551 (hosted MCP: remote OAuth connector for Claude.ai / ChatGPT).
-- Creates the two tables backing this install's OAuth authorization server:
--
-- KyteOAuthClient - RFC 7591 dynamically-registered OAuth clients
-- (Claude / ChatGPT register as public PKCE clients
-- before the authorization-code flow).
-- KyteOAuthCode - short-lived, single-use authorization codes issued at
-- /oauth/authorize and redeemed once at /oauth/token for
-- a freshly-minted scoped KyteMCPToken (the access token).
--
-- The OAuth layer is a front door that mints existing `kmcp_live_` tokens;
-- all downstream MCP enforcement (McpTokenStrategy, scopes, sessions, audit)
-- is unchanged. See docs/design/hosted-mcp-oauth.md and the model specs in
-- src/Mvc/Model/KyteOAuthClient.php + KyteOAuthCode.php.
--
-- Idempotent (CREATE TABLE IF NOT EXISTS) — safe to re-run; existing installs
-- that already created equivalent tables are unaffected. Conventions mirror
-- migrations/4.6.0_mcp_session_store.sql (BIGINT UNSIGNED ids, InnoDB,
-- utf8mb4). Portable to MySQL 8.x + MariaDB 10.5+.
-- =========================================================================

CREATE TABLE IF NOT EXISTS `KyteOAuthClient` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

`client_id` VARCHAR(64) NOT NULL COMMENT 'Public client identifier (opaque random) issued at registration',
`client_secret` VARCHAR(64) DEFAULT NULL COMMENT 'sha256 of confidential-client secret; NULL for public/PKCE clients',
`client_name` VARCHAR(255) DEFAULT NULL,
`redirect_uris` TEXT NOT NULL COMMENT 'JSON array of allowed redirect URIs (exact-matched at authorize/token)',
`grant_types` VARCHAR(255) DEFAULT NULL COMMENT 'CSV, default authorization_code',
`response_types` VARCHAR(255) DEFAULT NULL COMMENT 'CSV, default code',
`token_endpoint_auth_method` VARCHAR(64) DEFAULT NULL COMMENT 'none for public PKCE clients',
`scope` VARCHAR(512) DEFAULT NULL COMMENT 'Space-separated OAuth scopes requested at registration',

`kyte_account` BIGINT UNSIGNED DEFAULT 0 COMMENT 'Nullable/0 — clients register before any user authenticates',

`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_client_id` (`client_id`),
KEY `idx_account` (`kyte_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `KyteOAuthCode` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

`code_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 of the raw authorization code; raw code never stored',
`client_id` VARCHAR(64) NOT NULL COMMENT 'KyteOAuthClient.client_id this code was issued to',
`redirect_uri` VARCHAR(1024) NOT NULL COMMENT 'Must match exactly at /oauth/token',
`code_challenge` VARCHAR(255) NOT NULL COMMENT 'PKCE code_challenge (base64url sha256(verifier))',
`code_challenge_method` VARCHAR(16) NOT NULL COMMENT 'S256 only',
`scope` VARCHAR(512) DEFAULT NULL COMMENT 'Space-separated OAuth scopes granted at consent',
`kyte_scopes` VARCHAR(255) NOT NULL COMMENT 'CSV of kmcp scopes to mint (read/draft/commit/provision/schema)',
`application` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Optional app scope for the minted token',
`expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Unix epoch; codes are short-lived',
`consumed_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Unix epoch of redemption; 0 = unused (single-use guard)',

`kyte_account` BIGINT UNSIGNED NOT NULL COMMENT 'Account that consented; the minted token acts on this tenant',

`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT 'KyteUser who approved consent',
`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_code_hash` (`code_hash`),
KEY `idx_client` (`client_id`),
KEY `idx_expires` (`expires_at`),
KEY `idx_account` (`kyte_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
28 changes: 28 additions & 0 deletions migrations/4.18.0_application_status.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- =========================================================================
-- Kyte v4.18.0 - Application lifecycle status (async app teardown, KYTE-#559)
-- =========================================================================
-- IMPORTANT: Backup your database before running this migration.
--
-- Adds Application.status ('active' | 'deleting' | 'deleted'). When an app is
-- deleted it is marked 'deleting' (not dropped synchronously); the
-- SiteProvisioningWorker tears down each site's AWS infra (S3 + CloudFront +
-- ACM) then finalizes the app — drops the tenant DB + its user and sets
-- deleted=1/status='deleted'. This stops app deletion from orphaning site
-- infrastructure.
--
-- Portable + idempotent: MySQL has no `ADD COLUMN IF NOT EXISTS`, so guard with
-- an information_schema check + a prepared statement (works on MySQL 8.x +
-- MariaDB 10.5+). Existing rows default to 'active'.
-- =========================================================================

SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'Application' AND COLUMN_NAME = 'status'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE `Application` ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT ''active''',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
131 changes: 131 additions & 0 deletions migrations/4.19.0_app_sso.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
-- =========================================================================
-- Kyte v4.19.0 - App-level SSO (OIDC) tables (KYTE-#560)
-- =========================================================================
-- IMPORTANT: Backup your database before running this migration.
--
-- App-level Microsoft/OIDC SSO: an app's end users sign in with their identity
-- provider; Kyte (the relying party) validates the id_token and issues a Kyte
-- JWT session for the app's user_model.
--
-- KyteAppIdentityProvider - per-app OIDC config (Shipyard-managed; carries a
-- libsodium-encrypted client_secret; never via MCP).
-- KyteSsoState - short-lived in-flight state/nonce/PKCE per login,
-- plus a browser-binding correlator (login-CSRF).
-- KyteSsoCode - single-use hand-off code delivering the session to
-- the app front-end (no tokens at rest).
-- KyteSsoIdentity - subject->app-user link (the authoritative SSO join
-- key; email is never the identity key).
--
-- Idempotent (CREATE TABLE IF NOT EXISTS). Conventions mirror
-- migrations/4.6.0_mcp_session_store.sql. Portable MySQL 8.x / MariaDB 10.5+.
-- =========================================================================

CREATE TABLE IF NOT EXISTS `KyteAppIdentityProvider` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`application` BIGINT UNSIGNED NOT NULL,
`provider` VARCHAR(32) NOT NULL,
`enabled` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`issuer` VARCHAR(512) DEFAULT NULL,
`discovery_url` VARCHAR(512) DEFAULT NULL,
`tenant` VARCHAR(128) DEFAULT NULL,
`client_id` VARCHAR(255) DEFAULT NULL,
`client_secret` TEXT DEFAULT NULL COMMENT 'libsodium secretbox, base64(nonce+ciphertext)',
`scopes` VARCHAR(512) DEFAULT 'openid profile email',
`redirect_uri` VARCHAR(1024) DEFAULT NULL,
`user_email_claim` VARCHAR(64) DEFAULT 'email',
`jit_enabled` TINYINT UNSIGNED NOT NULL DEFAULT 1,
`restrict_to_existing` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`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,
KEY `idx_app_provider` (`application`, `provider`),
KEY `idx_account` (`kyte_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `KyteSsoState` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`state` VARCHAR(64) NOT NULL,
`nonce` VARCHAR(64) NOT NULL,
`code_verifier` VARCHAR(128) NOT NULL,
`application` BIGINT UNSIGNED NOT NULL,
`provider` VARCHAR(32) DEFAULT NULL,
`return_url` VARCHAR(1024) DEFAULT NULL,
`redirect_uri` VARCHAR(1024) DEFAULT NULL,
`browser_hash` VARCHAR(64) DEFAULT NULL,
`expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`consumed_at` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`kyte_account` BIGINT UNSIGNED DEFAULT 0,
`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_state` (`state`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `KyteSsoCode` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`code_hash` VARCHAR(64) NOT NULL,
`application` BIGINT UNSIGNED NOT NULL,
`sso_user_id` BIGINT UNSIGNED NOT NULL,
`expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`consumed_at` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`kyte_account` BIGINT UNSIGNED DEFAULT 0,
`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_code_hash` (`code_hash`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- KyteSsoIdentity: the federated-identity link binding an external IdP subject
-- to a local app user. This is the AUTHORITATIVE join key for SSO logins (the
-- immutable `sub` + tenant), NOT the mutable/unverified email claim — closing
-- the account-takeover vector where a token bearing another user's email would
-- otherwise be mapped onto that user's account.
CREATE TABLE IF NOT EXISTS `KyteSsoIdentity` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`application` BIGINT UNSIGNED NOT NULL,
`provider` VARCHAR(32) NOT NULL,
`subject` VARCHAR(255) NOT NULL,
`tenant_id` VARCHAR(128) DEFAULT NULL,
`sso_user_id` BIGINT UNSIGNED NOT NULL,
`email` VARCHAR(320) DEFAULT NULL,
`kyte_account` BIGINT UNSIGNED DEFAULT 0,
`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_app_provider_subject` (`application`, `provider`, `subject`),
KEY `idx_sso_user` (`application`, `sso_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ---------------------------------------------------------------------------
-- Idempotent ALTERs for installs that created the tables from an earlier cut
-- of this migration (before browser_hash / the identity table existed).
-- ---------------------------------------------------------------------------

-- KyteSsoState.browser_hash — login-CSRF browser-binding correlator.
SET @add_browser_hash := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `KyteSsoState` ADD COLUMN `browser_hash` VARCHAR(64) DEFAULT NULL AFTER `redirect_uri`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'KyteSsoState' AND COLUMN_NAME = 'browser_hash'
);
PREPARE stmt FROM @add_browser_hash; EXECUTE stmt; DEALLOCATE PREPARE stmt;
19 changes: 19 additions & 0 deletions src/Core/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,25 @@ public function route() {
return;
}

// /oauth/* (authorization server) and /.well-known/oauth-*
// (RFC 8414 / RFC 9728 discovery) are served by OAuthEndpoint — the
// OAuth 2.1 front door that lets Claude.ai / ChatGPT add /mcp as a
// hosted connector (KYTE-#551). Runs before the MVC pipeline, same
// rationale as /mcp and /jwt (own response shapes, pre-auth flow).
if (strcasecmp($firstSegment, 'oauth') === 0
|| stripos($path, '.well-known/oauth-') === 0) {
\Kyte\Core\Auth\OAuthEndpoint::handle($this);
return;
}

// /sso/* — app-level OIDC SSO. An app's end users sign in via their
// identity provider (Microsoft/Entra first) and get a Kyte JWT
// session (KYTE-#560). Runs before the MVC pipeline like /jwt, /mcp.
if (strcasecmp($firstSegment, 'sso') === 0) {
\Kyte\Core\Auth\SsoEndpoint::handle($this);
return;
}

if (isset($_SERVER['HTTP_X_KYTE_APPID'])) {
$this->appId = $_SERVER['HTTP_X_KYTE_APPID'];
}
Expand Down
41 changes: 40 additions & 1 deletion src/Core/Auth/JwtEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,45 @@ private static function passwordUpdate(array $body): array
* email user email (if available)
* app application identifier when app-scoped, omitted otherwise
*/
/**
* Issue a full Kyte session (access JWT + refresh token) for an already-
* authenticated user. Shared by /jwt/login and app-level SSO (KYTE-#560) so
* both hand back the identical session shape. `$app` is the resolved
* Application ModelObject (or null for platform/KyteUser).
*
* @return array<string,mixed>
*/
public static function issueSession(ModelObject $user, ModelObject $account, ?ModelObject $app, string $ip): array
{
$appIdentifier = $app !== null ? (string)$app->identifier : null;
$appId = $app !== null ? (int)$app->id : null;

$accessToken = self::mintAccessJwt($user, $account, $appIdentifier);
$refresh = RefreshTokenStore::issue((int)$user->id, (int)$account->id, $appId, $ip);

if (isset($user->kyte_model['struct']['lastLogin'])) {
try {
$user->save(['lastLogin' => time()]);
} catch (\Throwable $e) {
error_log('JwtEndpoint::issueSession lastLogin update failed - ' . $e->getMessage());
}
}

$userData = self::userToArray($user);
$useSessionMap = defined('USE_SESSION_MAP') && USE_SESSION_MAP;

return [
'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],
];
}

private static function mintAccessJwt(ModelObject $user, ModelObject $account, ?string $appIdentifier): string
{
if (!defined('KYTE_JWT_SECRET') || KYTE_JWT_SECRET === '') {
Expand Down Expand Up @@ -516,7 +555,7 @@ private static function mintAccessJwt(ModelObject $user, ModelObject $account, ?
*
* @return array{user_model: array, username_field: string, password_field: string, app: ?ModelObject}
*/
private static function resolveAuthContext(?string $appIdentifier): array
public static function resolveAuthContext(?string $appIdentifier): array
{
$defaultUserField = defined('USERNAME_FIELD') ? USERNAME_FIELD : 'email';
$defaultPassField = defined('PASSWORD_FIELD') ? PASSWORD_FIELD : 'password';
Expand Down
Loading
Loading