Merge branch '1.9.x' into migrate-away-from-blacksmith-based-runners

This commit is contained in:
Levi van Noort
2026-05-11 10:50:29 +02:00
committed by GitHub
4 changed files with 313 additions and 1 deletions
+18 -1
View File
@@ -55,7 +55,7 @@ class Google extends OAuth2
'state' => \json_encode($this->state),
'response_type' => 'code',
'access_type' => 'offline',
'prompt' => 'consent'
'prompt' => $this->getPrompt()
]);
}
@@ -190,6 +190,23 @@ class Google extends OAuth2
return $secret['clientSecret'] ?? $this->appSecret;
}
/**
* Extracts the prompt values from the JSON stored in appSecret
*
* @return string
*/
protected function getPrompt(): string
{
$secret = $this->getAppSecret();
$prompt = $secret['prompt'] ?? [];
if (empty($prompt)) {
$prompt = ['consent'];
}
return \implode(' ', $prompt);
}
/**
* Decode the JSON stored in appSecret.
* Falls back to treating the raw string as the client secret for backwards compatibility.
@@ -3,8 +3,22 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google;
use Appwrite\Auth\OAuth2\Google;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Update extends Base
{
@@ -52,4 +66,118 @@ class Update extends Base
{
return 'GOCSPX-2k8gsR0000000000000000VNahJj';
}
public static function getParameters(): array
{
return \array_merge(parent::getParameters(), [
[
'$id' => 'prompt',
'name' => 'Prompt',
'example' => '["consent"]',
'hint' => '',
],
]);
}
public function __construct()
{
$providerId = static::getProviderId();
$providerLabel = static::getProviderLabel();
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/project/oauth2/' . $providerId)
->desc('Update project OAuth2 ' . $providerLabel)
->groups(['api', 'project'])
->label('scope', 'oauth2.write')
->label('event', 'oauth2.[providerId].update')
->label('audits.event', 'project.oauth2.[providerId].update')
->label('audits.resource', 'project.oauth2/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
group: 'oauth2',
name: static::getProviderSDKMethod(),
description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: static::getResponseModel(),
)
],
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('prompt', null, new Nullable(new ArrayList(new WhiteList(['none', 'consent', 'select_account'], true), 3)), 'Array of Google OAuth2 prompt values. If "none" is included, it must be the only element. "none" means: don\'t display any authentication or consent screens. Must not be specified with other values. "consent" means: prompt the user for consent. "select_account" means: prompt the user to select an account.', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
->inject('project')
->inject('authorization')
->inject('queueForEvents')
->callback($this->handle(...));
}
public function buildReadResponse(Document $project): Document
{
$providerId = static::getProviderId();
$oAuthProviders = $project->getAttribute('oAuthProviders', []);
$decoded = $this->decodeStoredSecret($project);
return new Document([
'$id' => $providerId,
'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
static::getClientSecretParamName() => '',
'prompt' => $decoded['prompt'] ?? ['consent'],
]);
}
/**
* Custom callback used instead of the parent's `action()` because Google
* takes an additional optional `prompt` parameter. The method is named
* differently to avoid an LSP-incompatible override of Base::action().
*/
public function handle(
?string $clientId,
?string $clientSecret,
?array $prompt,
?bool $enabled,
Response $response,
Database $dbForPlatform,
Document $project,
Authorization $authorization,
QueueEvent $queueForEvents
): void {
$providerId = static::getProviderId();
$queueForEvents->setParam('providerId', $providerId);
if ($prompt !== null) {
if (empty($prompt)) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Prompt array cannot be empty.');
}
if (\in_array('none', $prompt) && \count($prompt) > 1) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When "none" is used as a prompt value, it must be the only element in the array.');
}
}
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = $this->decodeStoredSecret($project);
// Backwards compatibility: secrets stored before the prompt feature
// were saved as plain strings. Treat the raw value as clientSecret.
if (!empty($storedRaw) && empty($existing)) {
$existing = ['clientSecret' => $storedRaw];
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
'prompt' => $prompt ?? ($existing['prompt'] ?? ['consent']),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
$response->dynamic($this->buildReadResponse($project), static::getResponseModel());
}
}
@@ -25,6 +25,20 @@ class OAuth2Google extends OAuth2Base
return 'GOCSPX-2k8gsR0000000000000000VNahJj';
}
public function __construct()
{
parent::__construct();
$this->addRule('prompt', [
'type' => self::TYPE_ENUM,
'description' => 'Google OAuth2 prompt values.',
'default' => ['consent'],
'example' => ['consent'],
'array' => true,
'enum' => ['none', 'consent', 'select_account'],
]);
}
/**
* Get Name
*
+153
View File
@@ -2564,6 +2564,159 @@ trait OAuth2Base
]);
}
// =========================================================================
// Update Google (clientId + clientSecret + optional prompt)
// =========================================================================
/**
* Default prompt MUST run before any other Google test that sets a custom
* prompt value. The global resetProjectOAuth2() only clears Amazon state,
* so Google state leaks across tests in the same class. Running this first
* guarantees the stored JSON blob has no pre-existing "prompt" key.
*/
public function testUpdateOAuth2GoogleDefaultPrompt(): void
{
// When prompt is omitted and nothing is stored, the default is ['consent'].
$response = $this->updateOAuth2('google', [
'clientId' => 'google-default-client',
'clientSecret' => 'google-default-secret',
'enabled' => false,
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame(['consent'], $response['body']['prompt']);
// Cleanup
$this->updateOAuth2('google', [
'clientId' => '',
'clientSecret' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2Google(): void
{
$response = $this->updateOAuth2('google', [
'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
'prompt' => ['select_account'],
'enabled' => false,
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('google', $response['body']['$id']);
$this->assertSame('120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com', $response['body']['clientId']);
$this->assertSame(['select_account'], $response['body']['prompt']);
// Cleanup
$this->updateOAuth2('google', [
'clientId' => '',
'clientSecret' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2GooglePartialPreservesPrompt(): void
{
// Seed clientSecret + prompt.
$this->updateOAuth2('google', [
'clientId' => 'google-seed-client',
'clientSecret' => 'google-seed-secret',
'prompt' => ['consent', 'select_account'],
'enabled' => false,
]);
// Update only clientId.
$response = $this->updateOAuth2('google', [
'clientId' => 'google-rotated-client',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('google-rotated-client', $response['body']['clientId']);
$this->assertSame(['consent', 'select_account'], $response['body']['prompt']);
// Cleanup
$this->updateOAuth2('google', [
'clientId' => '',
'clientSecret' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2GooglePromptNoneAloneRejected(): void
{
$response = $this->updateOAuth2('google', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'prompt' => ['none', 'consent'],
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
}
public function testUpdateOAuth2GooglePromptEmptyArrayRejected(): void
{
$response = $this->updateOAuth2('google', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'prompt' => [],
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
}
public function testUpdateOAuth2GooglePromptNoneAloneAccepted(): void
{
$response = $this->updateOAuth2('google', [
'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
'prompt' => ['none'],
'enabled' => false,
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame(['none'], $response['body']['prompt']);
// Cleanup
$this->updateOAuth2('google', [
'clientId' => '',
'clientSecret' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2GoogleEnableAndReadBack(): void
{
$update = $this->updateOAuth2('google', [
'clientId' => 'google-enable-client',
'clientSecret' => 'google-enable-secret',
'prompt' => ['select_account'],
'enabled' => true,
]);
$this->assertSame(200, $update['headers']['status-code']);
$this->assertTrue($update['body']['enabled']);
// GET must hide clientSecret while keeping clientId and prompt.
$get = $this->getOAuth2Provider('google');
$this->assertSame(200, $get['headers']['status-code']);
$this->assertTrue($get['body']['enabled']);
$this->assertSame('google-enable-client', $get['body']['clientId']);
$this->assertSame(['select_account'], $get['body']['prompt']);
$this->assertSame('', $get['body']['clientSecret']);
// Cleanup
$this->updateOAuth2('google', [
'clientId' => '',
'clientSecret' => '',
'enabled' => false,
]);
}
// =========================================================================
// Smoke test: every plain (clientId + clientSecret) provider
//