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
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<file>tests/ApiTest.php</file>
<file>tests/DatabaseTest.php</file>
<file>tests/FunctionTest.php</file>
<file>tests/HmacSessionStrategyTest.php</file>
<file>tests/ModelTest.php</file>
<file>tests/SignatureTest.php</file>
</testsuite>
Expand Down
67 changes: 55 additions & 12 deletions src/Core/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ class Api

/**
* @var \Kyte\Core\ModelObject The KyteAPIKey model object.
* Public to allow AuthStrategy implementations to populate during auth.
*/
private $key = null;
public $key = null;

/**
* * @var \Kyte\Core\ModelObject The KyteAccount model object.
Expand Down Expand Up @@ -52,15 +53,17 @@ class Api
* The API signature.
*
* @var string|null
* Public to allow AuthStrategy implementations to populate during auth.
*/
private $signature = null;
public $signature = null;

/**
* The UTC date.
*
* @var mixed|null
* Public to allow AuthStrategy implementations to populate during auth.
*/
private $utcDate = null;
public $utcDate = null;

/**
* The HTTP request model.
Expand Down Expand Up @@ -171,8 +174,17 @@ class Api
'CHECK_SYNTAX_ON_IMPORT' => false,
'STRICT_TYPING' => true,
'KYTE_USE_SNS' => false,
'AUTH_STRATEGY_DISPATCHER' => 'off',
];

/**
* Auth strategy selected for the current request (set by validateRequest
* when AUTH_STRATEGY_DISPATCHER != 'off'). Null on the legacy path.
*
* @var \Kyte\Core\Auth\AuthStrategy|null
*/
public $authStrategy = null;

/**
* Model definition cache
*
Expand Down Expand Up @@ -935,16 +947,39 @@ private function validateRequest()
error_log(print_r($this->data, true));
}

if (IS_PRIVATE) {
$this->signature = isset($_SERVER['HTTP_X_KYTE_SIGNATURE']) ? $_SERVER['HTTP_X_KYTE_SIGNATURE'] : null;
if (!$this->signature) {
// Shadow mode snapshots the pre-auth response so the new strategy
// can be re-run from the same starting state after legacy completes.
$shadowEntryResponse = null;
if (AUTH_STRATEGY_DISPATCHER === 'shadow') {
$shadowEntryResponse = $this->response;
}

if (AUTH_STRATEGY_DISPATCHER === 'on') {
// New strategy-dispatcher path. Functionally equivalent to the
// legacy branch below when HmacSessionStrategy matches.
$this->authStrategy = \Kyte\Core\Auth\AuthDispatcher::buildDefault()->select();
if (VERBOSE_LOG > 0) {
error_log('auth: strategy_selected=' . ($this->authStrategy ? $this->authStrategy->name() : 'null'));
}
if ($this->authStrategy === null) {
return false;
}
}
$this->authStrategy->preAuth($this);
if (!$this->account) {
return false;
}
} else {
if (IS_PRIVATE) {
$this->signature = isset($_SERVER['HTTP_X_KYTE_SIGNATURE']) ? $_SERVER['HTTP_X_KYTE_SIGNATURE'] : null;
if (!$this->signature) {
return false;
}
}

$this->parseIdentityString(isset($_SERVER['HTTP_X_KYTE_IDENTITY']) ? $_SERVER['HTTP_X_KYTE_IDENTITY'] : null);
if (!$this->account) {
return false;
$this->parseIdentityString(isset($_SERVER['HTTP_X_KYTE_IDENTITY']) ? $_SERVER['HTTP_X_KYTE_IDENTITY'] : null);
if (!$this->account) {
return false;
}
}

// set page size
Expand Down Expand Up @@ -1005,11 +1040,19 @@ private function validateRequest()

// default is always public.
// this can be bypassed for public APIs but is highly discouraged
if (IS_PRIVATE) {
if (AUTH_STRATEGY_DISPATCHER === 'on') {
$this->authStrategy->verify($this);
} elseif (IS_PRIVATE) {
// VERIFY SIGNATURE
$this->verifySignature();
}

// Shadow: legacy auth is fully applied at this point. Re-run the
// new dispatcher against a reset state and log any divergence.
if (AUTH_STRATEGY_DISPATCHER === 'shadow') {
\Kyte\Core\Auth\AuthShadowHarness::runAndCompare($this, $shadowEntryResponse);
}

return true;
}

Expand Down
54 changes: 54 additions & 0 deletions src/Core/Auth/AuthDispatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php
namespace Kyte\Core\Auth;

use Kyte\Core\Api;

/**
* Selects an auth strategy for the current request by consulting each
* registered strategy's matches() method in order. First match wins;
* no further strategies are consulted.
*
* Returns the selected strategy (or null if none claimed the request).
* Does not invoke preAuth/verify — the caller runs those at the correct
* points in the request lifecycle to preserve Api::validateRequest() ordering.
*
* See docs/design/kyte-mcp-and-auth-migration.md section 3.5.
*/
class AuthDispatcher
{
/** @var AuthStrategy[] */
private $strategies;

/**
* @param AuthStrategy[] $strategies Ordered list. First match wins.
*/
public function __construct(array $strategies)
{
$this->strategies = $strategies;
}

/**
* Returns the first strategy whose matches() returns true, or null.
*/
public function select(): ?AuthStrategy
{
foreach ($this->strategies as $strategy) {
if ($strategy->matches()) {
return $strategy;
}
}
return null;
}

/**
* Convenience constructor wiring the default strategy stack for the
* current migration state. Phase 1: Hmac only. Phase 2 adds McpToken.
* Phase 3 adds JwtSession.
*/
public static function buildDefault(): self
{
return new self([
new HmacSessionStrategy(),
]);
}
}
140 changes: 140 additions & 0 deletions src/Core/Auth/AuthShadowHarness.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php
namespace Kyte\Core\Auth;

use Kyte\Core\Api;
use Kyte\Core\ModelObject;

/**
* Live shadow-mode harness: re-runs auth via the new AuthDispatcher
* against a controlled-reset Api state, compares the result to the
* legacy path's outcome, and logs any discrepancy.
*
* Enabled by setting AUTH_STRATEGY_DISPATCHER=shadow. Dev-only.
*
* Runs AFTER the legacy path has completed. The legacy result is
* restored before we return, so the response served to the client is
* always the legacy one. Shadow never affects the served response.
*
* Known side effects during shadow:
* - SessionManager::validate() runs twice per request (legacy, then new).
* Each call may update a session's last-activity timestamp. Acceptable
* during the Phase 1 soak on dev; not for production customers.
* - DB reads for account + api key occur twice per request.
*/
class AuthShadowHarness
{
/**
* Compare the new dispatcher's auth result against the legacy result
* already applied to $api. Restores legacy state on exit.
*
* @param array $entryResponse $api->response as it was BEFORE legacy
* auth ran — so the new path starts from the same initial state.
*/
public static function runAndCompare(Api $api, array $entryResponse): void
{
$legacyFingerprint = self::fingerprint($api);

$legacyKey = $api->key;
$legacyAccount = $api->account;
$legacyUser = $api->user;
$legacyResponse = $api->response;
$legacySignature = $api->signature;
$legacyUtcDate = $api->utcDate;

// Reset to the state Api::route() leaves us in just before validateRequest.
$api->key = new ModelObject(KyteAPIKey);
$api->account = new ModelObject(KyteAccount);
$api->user = null;
$api->signature = null;
$api->utcDate = null;
$api->response = $entryResponse;

$strategy = null;
try {
$strategy = AuthDispatcher::buildDefault()->select();
if ($strategy !== null) {
$strategy->preAuth($api);
$strategy->verify($api);
}
$newFingerprint = self::fingerprint($api);

$diff = self::diff($legacyFingerprint, $newFingerprint);
if (!empty($diff)) {
self::logDiff($diff, $strategy);
}
} catch (\Throwable $e) {
self::logException($e, $strategy);
} finally {
$api->key = $legacyKey;
$api->account = $legacyAccount;
$api->user = $legacyUser;
$api->response = $legacyResponse;
$api->signature = $legacySignature;
$api->utcDate = $legacyUtcDate;
}
}

private static function fingerprint(Api $api): array
{
return [
'key_id' => ($api->key && isset($api->key->id)) ? $api->key->id : null,
'account_id' => ($api->account && isset($api->account->id)) ? $api->account->id : null,
'user_id' => ($api->user && isset($api->user->id)) ? $api->user->id : null,
'session' => $api->response['session'] ?? null,
'token' => $api->response['token'] ?? null,
'uid' => $api->response['uid'] ?? null,
'name' => $api->response['name'] ?? null,
'email' => $api->response['email'] ?? null,
'signature' => $api->signature,
'utc_epoch' => $api->utcDate ? $api->utcDate->format('U') : null,
];
}

private static function diff(array $legacy, array $new): array
{
$out = [];
foreach ($legacy as $k => $v) {
$n = $new[$k] ?? null;
if ($v !== $n) {
$out[$k] = ['legacy' => $v, 'new' => $n];
}
}
return $out;
}

private static function logDiff(array $diff, ?AuthStrategy $strategy): void
{
try {
\Kyte\Core\ActivityLogger::getInstance()->log(
'AUTH_SHADOW_DIFF',
'AuthShadowHarness',
'strategy',
$strategy ? $strategy->name() : 'null',
null,
0,
'shadow',
json_encode($diff)
);
} catch (\Throwable $e) {
error_log('AuthShadowHarness: failed to log diff - ' . $e->getMessage());
}
}

private static function logException(\Throwable $e, ?AuthStrategy $strategy): void
{
try {
\Kyte\Core\ActivityLogger::getInstance()->log(
'AUTH_SHADOW_EXCEPTION',
'AuthShadowHarness',
'strategy',
$strategy ? $strategy->name() : 'null',
null,
0,
'shadow',
get_class($e) . ': ' . $e->getMessage()
);
} catch (\Throwable $inner) {
error_log('AuthShadowHarness: failed to log exception - ' . $inner->getMessage());
}
}
}
46 changes: 46 additions & 0 deletions src/Core/Auth/AuthStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php
namespace Kyte\Core\Auth;

use Kyte\Core\Api;

/**
* Contract for request-authentication strategies.
*
* A strategy inspects the request (headers, config) to decide whether it
* claims responsibility, then performs its two-phase auth work against a
* supplied Api instance.
*
* Two-phase split preserves current Api::validateRequest() ordering:
* 1. preAuth() — populate $api->key, $api->account, $api->user, $api->session,
* and the identity-related $api->response slots.
* 2. (caller performs URL parsing + response hydration)
* 3. verify() — final signature/token check. May be a no-op.
*
* See docs/design/kyte-mcp-and-auth-migration.md section 3.5.
*/
interface AuthStrategy
{
/**
* Does this strategy claim responsibility for the current request?
* Inspects headers and per-install config. Pure; no side effects.
*/
public function matches(): bool;

/**
* Phase 1: identity parse + user/account/session lookup.
* Populates Api state. Throws SessionException / Exception on failure.
*/
public function preAuth(Api $api): void;

/**
* Phase 2: final signature or token verification.
* Called after URL parsing + response hydration. May be a no-op
* (e.g. identity-only mode). Throws SessionException on failure.
*/
public function verify(Api $api): void;

/**
* Short label for logging and telemetry (e.g. "hmac_session").
*/
public function name(): string;
}
Loading
Loading