Merge pull request #9357 from appwrite/feat-authroized-previews

Feat: Authorized previews
This commit is contained in:
Matej Bačo
2025-03-08 21:04:25 +01:00
committed by GitHub
6 changed files with 294 additions and 5 deletions
+103
View File
@@ -3,6 +3,7 @@
require_once __DIR__ . '/../init.php';
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Key;
use Appwrite\Event\Certificate;
@@ -155,6 +156,80 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
$path .= '?' . $query;
}
$protocol = $request->getProtocol();
/**
Ensure preview authorization
- Authorization is skippable for tests, and build screenshot
- If cookie is not sent by client -> not authorized
- If JWT in cookie is invalid or expired -> not authorized
- If user is blocked or removed -> not authorized
- If user's session is removed or expired -> not authorized
- If user is not member of team of this deployment -> not authorized
- If not authorized, redirect to Console redirect UI
- If authorized, continue as if auth was not required
*/
$requirePreview = \is_null($apiKey) || !$apiKey->isPreviewAuthDisabled();
if ($isPreview && $requirePreview) {
$cookie = $request->getCookie(Auth::$cookieNamePreview, '');
$authorized = false;
// Security checks to mark authorized true
if (!empty($cookie)) {
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
$payload = [];
try {
$payload = $jwt->decode($cookie);
} catch (JWTException $error) {
// Authorized remains false
}
$userExists = false;
$userId = $payload['userId'] ?? '';
if (!empty($userId)) {
$user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId));
if (!$user->isEmpty() && $user->getAttribute('status', false)) {
$userExists = true;
}
}
$sessionExists = false;
$jwtSessionId = $payload['sessionId'] ?? '';
if (!empty($jwtSessionId) && !empty($user->find('$id', $jwtSessionId, 'sessions'))) {
$sessionExists = true;
}
$membershipExists = false;
$project = Authorization::skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if (!$project->isEmpty()) {
$teamId = $project->getAttribute('teamId', '');
$membership = $user->find('teamId', $teamId, 'memberships');
if (!empty($membership)) {
$membershipExists = true;
}
}
if ($userExists && $sessionExists && $membershipExists) {
$authorized = true;
}
}
if (!$authorized) {
$url = (System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https') . "://" . System::getEnv('_APP_DOMAIN');
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->redirect($url . '/console/auth/preview?'
. \http_build_query([
'projectId' => $projectId,
'origin' => $protocol . '://' . $host,
'path' => $path
]));
return true;
}
}
$body = $swooleRequest->getContent() ?? '';
$method = $swooleRequest->server['request_method'];
@@ -1272,6 +1347,34 @@ App::get('/v1/ping')
$response->text('Pong!');
});
// Preview authorization
App::get('/_appwrite/authorize')
->inject('request')
->inject('response')
->inject('previewHostname')
->action(function (Request $request, Response $response, string $previewHostname) {
$host = $request->getHostname() ?? '';
if (!empty($previewHostname)) {
$host = $previewHostname;
}
$referrer = $request->getReferer();
$protocol = \parse_url($request->getOrigin($referrer), PHP_URL_SCHEME);
$jwt = $request->getParam('jwt', '');
$path = $request->getParam('path', '');
$duration = 60 * 60 * 24; // 1 day in seconds
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$response
->addCookie(Auth::$cookieNamePreview, $jwt, (new \DateTime($expire))->getTimestamp(), '/', $host, ('https' === $protocol), true, null)
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->redirect($protocol . '://' . $host . $path);
});
App::wildcard()
->groups(['api'])
->label('scope', 'global')
+5
View File
@@ -103,6 +103,11 @@ class Auth
*/
public static $cookieName = 'a_session';
/**
* @var string
*/
public static $cookieNamePreview = 'a_jwt_console';
/**
* User Unique ID.
*
+9 -1
View File
@@ -23,6 +23,7 @@ class Key
protected bool $hostnameOverride = false,
protected bool $bannerDisabled = false,
protected bool $projectCheckDisabled = false,
protected bool $previewAuthDisabled = false,
) {
}
@@ -73,6 +74,11 @@ class Key
return $this->bannerDisabled;
}
public function isPreviewAuthDisabled(): bool
{
return $this->previewAuthDisabled;
}
public function isProjectCheckDisabled(): bool
{
return $this->projectCheckDisabled;
@@ -132,6 +138,7 @@ class Key
$hostnameOverride = $payload['hostnameOverride'] ?? false;
$bannerDisabled = $payload['bannerDisabled'] ?? false;
$projectCheckDisabled = $payload['projectCheckDisabled'] ?? false;
$previewAuthDisabled = $payload['previewAuthDisabled'] ?? false;
$scopes = \array_merge($payload['scopes'] ?? [], $scopes);
if (!$projectCheckDisabled && $projectId !== $project->getId()) {
@@ -148,7 +155,8 @@ class Key
$disabledMetrics,
$hostnameOverride,
$bannerDisabled,
$projectCheckDisabled
$projectCheckDisabled,
$previewAuthDisabled
);
case API_KEY_STANDARD:
$key = $project->find(
@@ -742,7 +742,8 @@ class Builds extends Action
$apiKey = $jwtObj->encode([
'hostnameOverride' => true,
'bannerDisabled' => true,
'projectCheckDisabled' => true
'projectCheckDisabled' => true,
'previewAuthDisabled' => true,
]);
// TODO: @Meldiron if becomes too slow, do concurrently
+2 -2
View File
@@ -164,7 +164,7 @@ class Client
* @return array
* @throws Exception
*/
public function call(string $method, string $path = '', array $headers = [], mixed $params = [], bool $decode = true): array
public function call(string $method, string $path = '', array $headers = [], mixed $params = [], bool $decode = true, bool $followRedirects = true): array
{
$headers = array_merge($this->headers, $headers);
$ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : ''));
@@ -192,7 +192,7 @@ class Client
curl_setopt($ch, CURLOPT_PATH_AS_IS, 1);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $followRedirects);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36');
curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
@@ -2,6 +2,7 @@
namespace Tests\E2E\Services\Sites;
use Ahc\Jwt\JWT;
use Appwrite\Platform\Modules\Compute\Specification;
use Appwrite\Tests\Retry;
use Tests\E2E\Client;
@@ -1519,8 +1520,18 @@ class SitesCustomServerTest extends Scope
$proxyClient = new Client();
$proxyClient->setEndpoint('http://' . $previewDomain);
$response = $proxyClient->call(Client::METHOD_GET, '/');
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringContainsString('/console/auth/preview', $response['headers']['location']);
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0);
$apiKey = $jwtObj->encode([
'projectCheckDisabled' => true,
'previewAuthDisabled' => true,
]);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Hello Appwrite", $response['body']);
$this->assertStringContainsString("Preview by", $response['body']);
@@ -1876,4 +1887,165 @@ class SitesCustomServerTest extends Scope
$this->cleanupSite($siteId);
}
public function testPreviewDomain(): void
{
$siteId = $this->setupSite([
'buildRuntime' => 'node-22',
'framework' => 'other',
'name' => 'Authorized preview site',
'siteId' => ID::unique(),
'adapter' => 'static',
]);
$this->assertNotEmpty($siteId);
$deploymentId = $this->setupDeployment($siteId, [
'code' => $this->packageSite('static'),
'activate' => true
]);
$this->assertNotEmpty($deploymentId);
$domain = $this->getDeploymentDomain($deploymentId);
$this->assertNotEmpty($domain);
$proxyClient = new Client();
$proxyClient->setEndpoint('http://' . $domain);
$response = $proxyClient->call(Client::METHOD_GET, '/contact', followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringContainsString('/console/auth/preview', $response['headers']['location']);
$this->assertStringContainsString('projectId=' . $this->getProject()['$id'], $response['headers']['location']);
$this->assertStringContainsString('origin=', $response['headers']['location']);
$this->assertStringContainsString('path=%2Fcontact', $response['headers']['location']);
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
]), [
'email' => $this->getRoot()['email'],
'password' => 'password'
]);
$this->assertEquals(201, $session['headers']['status-code']);
$this->assertNotEmpty($session['cookies']['a_session_console']);
$this->assertNotEmpty($session['body']['$id']);
$cookie = 'a_session_console=' . $session['cookies']['a_session_console'];
$jwt = $this->client->call(Client::METHOD_POST, '/account/jwts', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => $cookie,
'x-appwrite-project' => 'console',
]), []);
$this->assertEquals(201, $jwt['headers']['status-code']);
$this->assertNotEmpty($jwt['body']['jwt']);
$response = $proxyClient->call(Client::METHOD_GET, '/_appwrite/authorize', params: [
'jwt' => $jwt['body']['jwt'],
'path' => '/contact'
], followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertArrayHasKey('set-cookie', $response['headers']);
$this->assertStringContainsString('a_jwt_console=', $response['headers']['set-cookie']);
$this->assertStringContainsString('httponly', $response['headers']['set-cookie']);
$this->assertStringContainsString('domain=' . $domain, $response['headers']['set-cookie']);
$this->assertStringContainsString('path=/', $response['headers']['set-cookie']);
$this->assertNotEmpty($response['cookies']['a_jwt_console']);
$this->assertEquals($jwt['body']['jwt'], $response['cookies']['a_jwt_console']);
$response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [
'cookie' => 'a_jwt_console=' . $jwt['body']['jwt']
], followRedirects: false);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Contact page", $response['body']);
$this->assertStringContainsString("Preview by", $response['body']);
// Failure: Session missing (old bad, new ok)
$session = $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => $cookie,
'x-appwrite-project' => 'console',
]), []);
$this->assertEquals(204, $session['headers']['status-code']);
$response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [
'cookie' => 'a_jwt_console=' . $jwt['body']['jwt']
], followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringContainsString('/console/auth/preview', $response['headers']['location']);
// Failure: User missing
$cookie = 'a_session_console=' .$this->getRoot()['session'];
$jwt = $this->client->call(Client::METHOD_POST, '/account/jwts', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => $cookie,
'x-appwrite-project' => 'console',
]), []);
$this->assertEquals(201, $jwt['headers']['status-code']);
$this->assertNotEmpty($jwt['body']['jwt']);
$response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [
'cookie' => 'a_jwt_console=' . $jwt['body']['jwt']
], followRedirects: false);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Contact page", $response['body']);
$this->assertStringContainsString("Preview by", $response['body']);
$user = $this->client->call(Client::METHOD_PATCH, '/account/status', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => $cookie,
'x-appwrite-project' => 'console',
]), []);
$this->assertEquals(200, $user['headers']['status-code']);
$this->assertFalse($user['body']['status']);
$response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [
'cookie' => 'a_jwt_console=' . $jwt['body']['jwt']
], followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringContainsString('/console/auth/preview', $response['headers']['location']);
// Failure: Membership missing
$user = $this->client->call(Client::METHOD_POST, '/account', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
], [
'userId' => ID::unique(),
'email' => 'newuser@appwrite.io',
'password' => 'password'
]);
$this->assertEquals(201, $user['headers']['status-code']);
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
], [
'email' => 'newuser@appwrite.io',
'password' => 'password',
]);
$this->assertEquals(201, $session['headers']['status-code']);
$this->assertNotEmpty($session['cookies']['a_session_console']);
$cookie = 'a_session_console=' . $session['cookies']['a_session_console'];
$jwt = $this->client->call(Client::METHOD_POST, '/account/jwts', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => $cookie,
'x-appwrite-project' => 'console',
]), []);
$this->assertEquals(201, $jwt['headers']['status-code']);
$this->assertNotEmpty($jwt['body']['jwt']);
$response = $proxyClient->call(Client::METHOD_GET, '/contact', headers: [
'cookie' => 'a_jwt_console=' . $jwt['body']['jwt']
], followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringContainsString('/console/auth/preview', $response['headers']['location']);
$this->cleanupSite($siteId);
}
}