diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php index 6028bd109b..1166a313c6 100644 --- a/src/Appwrite/Auth/OAuth2/Google.php +++ b/src/Appwrite/Auth/OAuth2/Google.php @@ -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. diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php index 9b985f4aed..2a061d09ce 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -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()); + } } diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php index 3dbc892631..ebef9aecf7 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php @@ -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 * diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 47d92a2a58..5959a584ea 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -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 //