Skip to content

Commit ac52ffb

Browse files
kennethphoughclaude
andcommitted
JWT-mode anonymous/public API access — AppContextStrategy (KYTE-#229)
Server-side half of the two-repo change letting JWT-mode sites serve requireAuth=false controllers to anonymous visitors (public browsing before login) via x-kyte-appid only. - AppContextStrategy: strict header-only matches() (appid present, no Bearer/signature/identity → mutually exclusive with HMAC/JWT); preAuth resolves the app's account but NEVER a user and NEVER hasSession (the invariant that keeps requireAuth=true controllers at 403). Slotted in AuthDispatcher after Jwt, before Hmac. - Defense in depth: per-app opt-in Application.allow_public (default 0, migration 4.11.0_application_allow_public.sql) enforced in preAuth; ModelController restricts an app_context request to GET (read-only); AuthShadowHarness skips app_context (no legacy equivalent). - tests/AppContextStrategyTest.php (added to phpunit.xml.dist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4616606 commit ac52ffb

9 files changed

Lines changed: 357 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
## 4.11.0
2+
3+
### Feature: JWT-mode anonymous/public API access (AppContextStrategy) — KYTE-#229
4+
5+
Lets a site running in **JWT auth mode** (endpoint + appId, no embedded HMAC key/secret) serve `requireAuth=false` controllers to **anonymous visitors** (public/catalog browsing before any login) — something only HMAC mode could do before. Server-side half of the two-repo change (the kyte-api-js anonymous fall-through ships alongside).
6+
7+
**New `AppContextStrategy`** (`src/Core/Auth/AppContextStrategy.php`), slotted in `AuthDispatcher::buildDefault()` **after** `JwtSessionStrategy` and **before** `HmacSessionStrategy`:
8+
- `matches()` is strict and header-only — claims a request **only** when an `x-kyte-appid` is present **and** there is no `Authorization` Bearer, no `x-kyte-signature`, and no `x-kyte-identity`. Mutually exclusive with every authenticated flow, so it cannot shadow HMAC or JWT.
9+
- `preAuth()` resolves the application's **account** for query scoping but **never resolves a user and never sets `hasSession`**. That is the security invariant: `ModelController::authenticate()` throws unless both `$api->user->id` and `$api->session->hasSession` are set, so every `requireAuth=true` controller keeps returning 403 to anonymous requests — only `requireAuth=false` controllers are reachable.
10+
11+
**Defense in depth:**
12+
- **Per-app opt-in.** New `Application.allow_public` flag (default `0`; migration `migrations/4.11.0_application_allow_public.sql`). `preAuth()` rejects an appid-only request unless the app sets `allow_public=1` — anonymous access is never implicit.
13+
- **Read-only.** `ModelController` restricts an `app_context` request to `GET` regardless of the controller's `allowableActions`; anonymous writes are not possible.
14+
- **Shadow harness.** `AuthShadowHarness` skips `app_context` (no legacy equivalent to diff against during dispatcher rollout).
15+
16+
Audit attribution for anonymous requests uses `user_id=null` / session `'0'` (ActivityLogger already tolerates a null user). Existing HMAC and JWT-Bearer flows are unchanged. Tests: `tests/AppContextStrategyTest.php` (matches() truth table; `preAuth` resolves account but not user/hasSession; opt-in enforcement).
17+
118
## 4.10.0
219

320
### Feature: MCP draft/write — AI can draft and commit pages, controller functions, and scripts
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- =========================================================================
2+
-- Kyte v4.11.0 - add Application.allow_public (JWT-mode anonymous access)
3+
-- =========================================================================
4+
-- IMPORTANT: Backup your database before running this migration.
5+
--
6+
-- Adds the per-app opt-in flag for anonymous/public API access via
7+
-- AppContextStrategy (JWT-mode public storefronts). Default 0 = anonymous
8+
-- appid-only requests are rejected; set to 1 per app to allow read-only
9+
-- anonymous access to requireAuth=false controllers.
10+
--
11+
-- ADDITIVE / migration-first / inert on older code (older code ignores the
12+
-- column; new code defaults it to 0). Table name is PascalCase. ALGORITHM not
13+
-- pinned (engine auto-selects INSTANT for ADD COLUMN where available).
14+
--
15+
-- See src/Mvc/Model/Application.php + src/Core/Auth/AppContextStrategy.php.
16+
-- =========================================================================
17+
18+
ALTER TABLE `Application`
19+
ADD COLUMN `allow_public` INT UNSIGNED NOT NULL DEFAULT 0;

phpunit.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<testsuite name="unit">
55
<file>tests/ActivityLoggerSensitivityTest.php</file>
66
<file>tests/ApiTest.php</file>
7+
<file>tests/AppContextStrategyTest.php</file>
78
<file>tests/Bz2CodecTest.php</file>
89
<file>tests/ClientIpTest.php</file>
910
<file>tests/DatabaseTest.php</file>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
namespace Kyte\Core\Auth;
3+
4+
use Kyte\Core\Api;
5+
use Kyte\Core\ModelObject;
6+
use Kyte\Exception\SessionException;
7+
8+
/**
9+
* Anonymous application-context strategy — JWT-mode public/anonymous access.
10+
*
11+
* Lets a site running in JWT auth mode (endpoint + appId, no embedded HMAC
12+
* key/secret) serve `requireAuth=false` controllers to ANONYMOUS visitors
13+
* (no user, before any login) using only the `x-kyte-appid` header — no
14+
* Bearer, no HMAC identity/signature. This closes the gap where JWT mode
15+
* could not serve public/catalog browsing the way HMAC mode can.
16+
*
17+
* SECURITY INVARIANT (load-bearing): preAuth() resolves the application's
18+
* ACCOUNT for query scoping but deliberately resolves NO user and NEVER sets
19+
* `$api->session->hasSession`. ModelController::authenticate() throws unless
20+
* BOTH `$api->user->id` and `$api->session->hasSession` are set, so every
21+
* `requireAuth=true` controller keeps returning 403 to anonymous requests.
22+
* Only controllers that explicitly opt out (`requireAuth=false`) are
23+
* reachable, and even those are READ-ONLY for this path (ModelController
24+
* restricts an app_context request to GET).
25+
*
26+
* Per-app opt-in: an application must set `Application.allow_public = 1` to
27+
* serve anonymous requests. A non-opted-in app's appid-only request is
28+
* rejected in preAuth (so anonymous access is never silently enabled).
29+
*
30+
* matches() is STRICT and header-only: it claims a request ONLY when an
31+
* `x-kyte-appid` is present AND there is no Authorization Bearer, no
32+
* `x-kyte-signature`, and no `x-kyte-identity`. That is mutually exclusive
33+
* with every authenticated flow, so it can never shadow HMAC or JWT. Slotted
34+
* in AuthDispatcher AFTER JwtSessionStrategy and BEFORE HmacSessionStrategy.
35+
*/
36+
class AppContextStrategy implements AuthStrategy
37+
{
38+
public function name(): string
39+
{
40+
return 'app_context';
41+
}
42+
43+
/**
44+
* Claim only genuine anonymous app-only requests: an appid is present and
45+
* NO authenticated credential (Bearer / HMAC signature / HMAC identity)
46+
* accompanies it. Pure header inspection, no side effects.
47+
*/
48+
public function matches(): bool
49+
{
50+
if (empty($_SERVER['HTTP_X_KYTE_APPID'])) {
51+
return false;
52+
}
53+
// A Bearer token belongs to McpToken or Jwt; an identity/signature
54+
// belongs to Hmac. If any is present, this is not an anonymous request.
55+
if ($this->authorizationHeader() !== null) {
56+
return false;
57+
}
58+
if (!empty($_SERVER['HTTP_X_KYTE_SIGNATURE'])) {
59+
return false;
60+
}
61+
if (!empty($_SERVER['HTTP_X_KYTE_IDENTITY'])) {
62+
return false;
63+
}
64+
return true;
65+
}
66+
67+
/**
68+
* Establish anonymous application context: resolve the account from the
69+
* already-resolved app (Api::route() resolves $api->app from x-kyte-appid
70+
* before auth). Resolves NO user and never sets hasSession.
71+
*/
72+
public function preAuth(Api $api): void
73+
{
74+
if ($api->app === null || $api->appId === null) {
75+
throw new SessionException('Anonymous access requires a valid application context (x-kyte-appid).');
76+
}
77+
78+
// Per-app opt-in. Without an explicit Application.allow_public=1, an
79+
// appid-only request is rejected — anonymous access is never implicit.
80+
if ((int)($api->app->allow_public ?? 0) !== 1) {
81+
throw new SessionException('Anonymous access is not enabled for this application.');
82+
}
83+
84+
// Resolve the account from the application for query scoping only.
85+
$account = new ModelObject(KyteAccount);
86+
if (!$account->retrieve('id', (int)$api->app->kyte_account)) {
87+
throw new SessionException('Unable to resolve account for application.');
88+
}
89+
$api->account = $account;
90+
91+
// Anonymous response markers. NOT setting $api->user->id and NOT
92+
// touching $api->session->hasSession is the security invariant.
93+
$api->response['session'] = '0';
94+
$api->response['uid'] = '0';
95+
96+
// Language resolution from account/app only (no user).
97+
$finalLanguage = defined('APP_LANG') ? APP_LANG : 'en';
98+
if (isset($api->account->default_language) && !empty($api->account->default_language)) {
99+
$finalLanguage = $api->account->default_language;
100+
}
101+
if (isset($api->app->language) && !empty($api->app->language)) {
102+
$finalLanguage = $api->app->language;
103+
}
104+
\Kyte\Util\I18n::setLanguage($finalLanguage);
105+
}
106+
107+
/**
108+
* No signature/token to verify — anonymous app context is fully
109+
* established in preAuth.
110+
*/
111+
public function verify(Api $api): void
112+
{
113+
}
114+
115+
/**
116+
* Read the Authorization header across the SAPI variants (mirrors
117+
* JwtSessionStrategy / McpTokenStrategy).
118+
*/
119+
private function authorizationHeader(): ?string
120+
{
121+
$candidates = [
122+
$_SERVER['HTTP_AUTHORIZATION'] ?? null,
123+
$_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? null,
124+
];
125+
foreach ($candidates as $value) {
126+
if (is_string($value) && $value !== '') {
127+
return $value;
128+
}
129+
}
130+
if (\function_exists('apache_request_headers')) {
131+
$headers = \apache_request_headers();
132+
if (is_array($headers)) {
133+
foreach ($headers as $hname => $value) {
134+
if (strcasecmp($hname, 'Authorization') === 0 && is_string($value) && $value !== '') {
135+
return $value;
136+
}
137+
}
138+
}
139+
}
140+
return null;
141+
}
142+
}

src/Core/Auth/AuthDispatcher.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ public function select(): ?AuthStrategy
5252
*
5353
* Phase 2 (MCP) added McpTokenStrategy.
5454
* Phase 3 (JWT) appended JwtSessionStrategy between MCP and HMAC.
55+
* Phase 3 follow-on added AppContextStrategy between Jwt and HMAC — it
56+
* claims ONLY anonymous app-only requests (x-kyte-appid, no Bearer/
57+
* signature/identity), so it cannot shadow the authenticated flows above
58+
* or the HMAC catch-all below.
5559
* Each is opt-in by config: a token can only be claimed by a
5660
* strategy that's actually configured on the install.
5761
*/
@@ -60,6 +64,7 @@ public static function buildDefault(): self
6064
return new self([
6165
new McpTokenStrategy(),
6266
new JwtSessionStrategy(),
67+
new AppContextStrategy(),
6368
new HmacSessionStrategy(),
6469
]);
6570
}

src/Core/Auth/AuthShadowHarness.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ public static function runAndCompare(Api $api, array $entryResponse): void
5353
try {
5454
$strategy = AuthDispatcher::buildDefault()->select();
5555
if ($strategy !== null) {
56+
// AppContextStrategy (anonymous app-only) has no legacy
57+
// equivalent — the legacy HMAC path rejects appid-only
58+
// requests outright — so there is nothing to compare. Skip to
59+
// avoid spurious shadow diffs/exceptions during rollout. The
60+
// finally block still restores the pre-shadow Api state.
61+
if ($strategy->name() === 'app_context') {
62+
return;
63+
}
5664
$strategy->preAuth($api);
5765
$strategy->verify($api);
5866
}

src/Mvc/Controller/ModelController.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,17 @@ protected function init()
191191
$this->shipyard_init();
192192

193193
$this->hook_init();
194-
194+
195+
// Anonymous app-context (JWT-mode public access via AppContextStrategy)
196+
// is READ-ONLY: no user is resolved, so restrict to GET regardless of
197+
// the controller's allowableActions. Writes require an authenticated
198+
// user/session. Only fires in dispatcher mode when the anonymous
199+
// strategy was selected; every other path is unaffected.
200+
if (isset($this->api->authStrategy) && $this->api->authStrategy !== null
201+
&& $this->api->authStrategy->name() === 'app_context') {
202+
$this->allowableActions = array_values(array_intersect($this->allowableActions, ['get']));
203+
}
204+
195205
if ($this->requireAuth && !$this->internal) {
196206
$this->authenticate();
197207
}

src/Mvc/Model/Application.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,19 @@
181181
'default' => 'hmac',
182182
],
183183

184+
// Per-app opt-in for anonymous/public API access (AppContextStrategy,
185+
// JWT-mode public access). 0 = anonymous appid-only requests rejected
186+
// (default); 1 = requireAuth=false controllers are reachable
187+
// anonymously (read-only). See src/Core/Auth/AppContextStrategy.php.
188+
'allow_public' => [
189+
'type' => 'i',
190+
'required' => false,
191+
'size' => 1,
192+
'unsigned' => true,
193+
'default' => 0,
194+
'date' => false,
195+
],
196+
184197
// framework attributes
185198

186199
'kyte_account' => [

0 commit comments

Comments
 (0)