From 0fe906c538d4af9f76b41a520edb5024fa3f974b Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 13:21:04 +0530 Subject: [PATCH 01/11] feat: Add X OAuth 2.0 provider --- app/config/oAuthProviders.php | 11 ++ app/controllers/api/account.php | 48 ++++++- src/Appwrite/Auth/OAuth2/X.php | 236 ++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Auth/OAuth2/X.php diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index e6acd08c54..cda6459519 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -376,6 +376,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Wordpress', ], + 'x' => [ + 'name' => 'X', + 'developers' => 'https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code', + 'icon' => 'icon-twitter', + 'enabled' => true, + 'sandbox' => false, + 'form' => false, + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\X', + ], 'yahoo' => [ 'name' => 'Yahoo', 'developers' => 'https://developer.yahoo.com/oauth2/guide/flows_authcode/', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6d33b45f0b..d6f4561d65 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1375,10 +1375,25 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); + $loginURL = $oauth2->getLoginURL(); + + if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + $response->addCookie( + 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, + $oauth2->getPKCEVerifier(), + \time() + 300, + '/', + Config::getParam('cookieDomain'), + ('https' === $protocol), + true, + Response::COOKIE_SAMESITE_LAX + ); + } + $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($oauth2->getLoginURL()); + ->redirect($loginURL); }); Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') @@ -1511,6 +1526,20 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') /** @var Appwrite\Auth\OAuth2 $oauth2 */ $oauth2 = new $className($appId, $appSecret, $callback); + if ($provider === 'x' && \method_exists($oauth2, 'setPKCEVerifier')) { + $oauth2->setPKCEVerifier($request->getCookie('a_oauth2_pkce_' . $project->getId() . '_' . $provider, '')); + $response->addCookie( + 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, + '', + \time() - 3600, + '/', + Config::getParam('cookieDomain'), + ('https' === $protocol), + true, + Response::COOKIE_SAMESITE_LAX + ); + } + if (!empty($state)) { try { $state = \array_merge($defaultState, $oauth2->parseState($state)); @@ -2079,10 +2108,25 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); + $loginURL = $oauth2->getLoginURL(); + + if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + $response->addCookie( + 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, + $oauth2->getPKCEVerifier(), + \time() + 300, + '/', + Config::getParam('cookieDomain'), + ('https' === $protocol), + true, + Response::COOKIE_SAMESITE_LAX + ); + } + $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($oauth2->getLoginURL()); + ->redirect($loginURL); }); Http::post('/v1/account/tokens/magic-url') diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php new file mode 100644 index 0000000000..b309f90c0f --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -0,0 +1,236 @@ + 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => \json_encode($this->state), + 'code_challenge' => $this->getCodeChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @return string + */ + public function getPKCEVerifier(): string + { + if (empty($this->pkceVerifier)) { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); + } + + return $this->pkceVerifier; + } + + /** + * @param string $pkceVerifier + * + * @return void + */ + public function setPKCEVerifier(string $pkceVerifier): void + { + $this->pkceVerifier = $pkceVerifier; + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + if (empty($this->pkceVerifier)) { + throw new Exception(\json_encode([ + 'error' => 'invalid_request', + 'error_description' => 'Missing PKCE verifier.', + ]), 400); + } + + $headers = [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + + $this->tokens = \json_decode($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + ]) + ), true); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + + $this->tokens = \json_decode($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $headers, + \http_build_query([ + 'client_id' => $this->appID, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['id'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['confirmed_email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * X returns a confirmed email only when the app has email access enabled + * and the authenticated user has a confirmed email address. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['data']['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $this->user = \json_decode($this->request( + 'GET', + 'https://api.x.com/2/users/me?user.fields=confirmed_email', + ['Authorization: Bearer ' . \urlencode($accessToken)] + ), true); + } + + return $this->user; + } + + /** + * @return string + */ + private function getCodeChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + + /** + * @param string $value + * + * @return string + */ + private function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } +} From 8218f36d340095aa7fb6cb40c3f225698c745673 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 13:32:06 +0530 Subject: [PATCH 02/11] code rabbit comment --- src/Appwrite/Auth/OAuth2/X.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index b309f90c0f..105c404d1f 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -209,7 +209,7 @@ class X extends OAuth2 $this->user = \json_decode($this->request( 'GET', 'https://api.x.com/2/users/me?user.fields=confirmed_email', - ['Authorization: Bearer ' . \urlencode($accessToken)] + ['Authorization: Bearer ' . $accessToken] ), true); } From dc48bb35efb52489b130b0a7de84400d9dba5a16 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 17:49:42 +0530 Subject: [PATCH 03/11] added pkce to base --- app/controllers/api/account.php | 6 ++-- src/Appwrite/Auth/OAuth2.php | 53 ++++++++++++++++++++++++++++++ src/Appwrite/Auth/OAuth2/Etsy.php | 29 ++++++----------- src/Appwrite/Auth/OAuth2/X.php | 54 ++++++------------------------- 4 files changed, 75 insertions(+), 67 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d6f4561d65..3d156d7d81 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1377,7 +1377,7 @@ Http::get('/v1/account/sessions/oauth2/:provider') $loginURL = $oauth2->getLoginURL(); - if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, $oauth2->getPKCEVerifier(), @@ -1526,7 +1526,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') /** @var Appwrite\Auth\OAuth2 $oauth2 */ $oauth2 = new $className($appId, $appSecret, $callback); - if ($provider === 'x' && \method_exists($oauth2, 'setPKCEVerifier')) { + if ($oauth2->usesPKCE()) { $oauth2->setPKCEVerifier($request->getCookie('a_oauth2_pkce_' . $project->getId() . '_' . $provider, '')); $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, @@ -2110,7 +2110,7 @@ Http::get('/v1/account/tokens/oauth2/:provider') $loginURL = $oauth2->getLoginURL(); - if ($provider === 'x' && \method_exists($oauth2, 'getPKCEVerifier')) { + if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, $oauth2->getPKCEVerifier(), diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 9358c89547..5e884b7bad 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -31,6 +31,11 @@ abstract class OAuth2 */ protected array $scopes; + /** + * @var string + */ + protected string $pkceVerifier = ''; + /** * OAuth2 constructor. * @@ -105,6 +110,14 @@ abstract class OAuth2 */ abstract public function getUserName(string $accessToken): string; + /** + * @return bool + */ + public function usesPKCE(): bool + { + return false; + } + /** * @param $scope * @@ -128,6 +141,36 @@ abstract class OAuth2 return $this->scopes; } + /** + * @return string + */ + public function getPKCEVerifier(): string + { + if (empty($this->pkceVerifier)) { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); + } + + return $this->pkceVerifier; + } + + /** + * @param string $pkceVerifier + * + * @return void + */ + public function setPKCEVerifier(string $pkceVerifier): void + { + $this->pkceVerifier = $pkceVerifier; + } + + /** + * @return string + */ + protected function getPKCEChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + /** * @param string $code * @@ -214,4 +257,14 @@ abstract class OAuth2 return (string)$response; } + + /** + * @param string $value + * + * @return string + */ + protected function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } } diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 7ff16fcb78..122838078d 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -34,23 +34,6 @@ class Etsy extends OAuth2 "profile_r", ]; - /** - * @var string - */ - private string $pkce = ''; - - /** - * @return string - */ - private function getPKCE(): string - { - if (empty($this->pkce)) { - $this->pkce = \bin2hex(\random_bytes(rand(43, 128))); - } - - return $this->pkce; - } - /** * @return string */ @@ -59,6 +42,14 @@ class Etsy extends OAuth2 return 'etsy'; } + /** + * @return bool + */ + public function usesPKCE(): bool + { + return true; + } + /** * @return string */ @@ -70,7 +61,7 @@ class Etsy extends OAuth2 'response_type' => 'code', 'state' => \json_encode($this->state), 'scope' => $this->scopes, - 'code_challenge' => $this->getPKCE(), + 'code_challenge' => $this->getPKCEChallenge(), 'code_challenge_method' => 'S256', ]); } @@ -94,7 +85,7 @@ class Etsy extends OAuth2 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'code' => $code, - 'code_verifier' => $this->getPKCE(), + 'code_verifier' => $this->getPKCEVerifier(), ]) ), true); } diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 105c404d1f..0cf5689626 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -29,11 +29,6 @@ class X extends OAuth2 'offline.access', ]; - /** - * @var string - */ - private string $pkceVerifier = ''; - /** * @return string */ @@ -42,6 +37,14 @@ class X extends OAuth2 return 'x'; } + /** + * @return bool + */ + public function usesPKCE(): bool + { + return true; + } + /** * @return string */ @@ -53,33 +56,11 @@ class X extends OAuth2 'redirect_uri' => $this->callback, 'scope' => \implode(' ', $this->getScopes()), 'state' => \json_encode($this->state), - 'code_challenge' => $this->getCodeChallenge(), + 'code_challenge' => $this->getPKCEChallenge(), 'code_challenge_method' => 'S256', ]); } - /** - * @return string - */ - public function getPKCEVerifier(): string - { - if (empty($this->pkceVerifier)) { - $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); - } - - return $this->pkceVerifier; - } - - /** - * @param string $pkceVerifier - * - * @return void - */ - public function setPKCEVerifier(string $pkceVerifier): void - { - $this->pkceVerifier = $pkceVerifier; - } - /** * @param string $code * @@ -216,21 +197,4 @@ class X extends OAuth2 return $this->user; } - /** - * @return string - */ - private function getCodeChallenge(): string - { - return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); - } - - /** - * @param string $value - * - * @return string - */ - private function base64UrlEncode(string $value): string - { - return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); - } } From 85703d29e1638a134ae8aa20d66bb97d51d46134 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 23 Mar 2026 19:07:36 +0530 Subject: [PATCH 04/11] addressed greptile suggestions --- app/controllers/api/account.php | 14 ++++++++++++-- src/Appwrite/Auth/OAuth2/X.php | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5a62c9bf21..d6ccc06a52 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1397,12 +1397,17 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); + $pkceVerifier = ''; + if ($oauth2->usesPKCE()) { + $pkceVerifier = $oauth2->getPKCEVerifier(); + } + $loginURL = $oauth2->getLoginURL(); if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $oauth2->getPKCEVerifier(), + $pkceVerifier, \time() + 300, '/', Config::getParam('cookieDomain'), @@ -2135,12 +2140,17 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); + $pkceVerifier = ''; + if ($oauth2->usesPKCE()) { + $pkceVerifier = $oauth2->getPKCEVerifier(); + } + $loginURL = $oauth2->getLoginURL(); if ($oauth2->usesPKCE()) { $response->addCookie( 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $oauth2->getPKCEVerifier(), + $pkceVerifier, \time() + 300, '/', Config::getParam('cookieDomain'), diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 0cf5689626..31eeecbb1d 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -24,6 +24,7 @@ class X extends OAuth2 * @var array */ protected array $scopes = [ + 'tweet.read', 'users.read', 'users.email', 'offline.access', From 614db7388eff389ac34f9379c573d127ec49f0fc Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Thu, 26 Mar 2026 17:40:28 +0530 Subject: [PATCH 05/11] fix: push --- .env | 8 +-- app/controllers/api/account.php | 116 ++++++++++-------------------- app/controllers/mock.php | 41 +++++++++++ src/Appwrite/Auth/OAuth2.php | 53 -------------- src/Appwrite/Auth/OAuth2/Etsy.php | 29 +++++--- src/Appwrite/Auth/OAuth2/X.php | 21 ------ 6 files changed, 100 insertions(+), 168 deletions(-) diff --git a/.env b/.env index 9abfa756e1..1b1a55d6f4 100644 --- a/.env +++ b/.env @@ -9,7 +9,7 @@ _APP_CONSOLE_WHITELIST_EMAILS= _APP_CONSOLE_SESSION_ALERTS=enabled _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_COUNTRIES_DENYLIST=AQ -_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io +_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io,posted-costumes-alphabetical-census.trycloudflare.com _APP_CONSOLE_SCHEMA=appwriteio _APP_MIGRATION_HOST=appwrite _APP_SYSTEM_EMAIL_NAME=Appwrite @@ -25,8 +25,8 @@ _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key _APP_DNS=172.16.238.100 # CoreDNS -_APP_DOMAIN=appwrite.test -_APP_CONSOLE_DOMAIN=localhost +_APP_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com +_APP_CONSOLE_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com _APP_CONSOLE_TRUSTED_PROJECTS=trusted-project,another-trusted-project _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost,rebranded.localhost @@ -143,6 +143,6 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10 _APP_PROJECT_REGIONS=default _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000 _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main -_APP_TRUSTED_HEADERS=x-forwarded-for +_APP_TRUSTED_HEADERS=x-forwarded-for,x-forwarded-proto,x-forwarded-host,x-forwarded-port _APP_POOL_ADAPTER=stack _APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d6ccc06a52..72347eaf9d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1341,12 +1341,13 @@ Http::get('/v1/account/sessions/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1397,26 +1398,8 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); - $pkceVerifier = ''; - if ($oauth2->usesPKCE()) { - $pkceVerifier = $oauth2->getPKCEVerifier(); - } - $loginURL = $oauth2->getLoginURL(); - if ($oauth2->usesPKCE()) { - $response->addCookie( - 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $pkceVerifier, - \time() + 300, - '/', - Config::getParam('cookieDomain'), - ('https' === $protocol), - true, - Response::COOKIE_SAMESITE_LAX - ); - } - $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') @@ -1438,12 +1421,13 @@ Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1474,12 +1458,13 @@ Http::post('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1526,12 +1511,13 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForToken') ->inject('authorization') ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -1553,20 +1539,6 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') /** @var Appwrite\Auth\OAuth2 $oauth2 */ $oauth2 = new $className($appId, $appSecret, $callback); - if ($oauth2->usesPKCE()) { - $oauth2->setPKCEVerifier($request->getCookie('a_oauth2_pkce_' . $project->getId() . '_' . $provider, '')); - $response->addCookie( - 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - '', - \time() - 3600, - '/', - Config::getParam('cookieDomain'), - ('https' === $protocol), - true, - Response::COOKIE_SAMESITE_LAX - ); - } - if (!empty($state)) { try { $state = \array_merge($defaultState, $oauth2->parseState($state)); @@ -2082,12 +2054,13 @@ Http::get('/v1/account/tokens/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $callbackBase .= ':' . $port; } @@ -2117,12 +2090,13 @@ Http::get('/v1/account/tokens/oauth2/:provider') } $host = $platform['consoleHostname'] ?? ''; - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); + $protocol = $request->getProtocol(); + $port = (string) $request->getPort(); $redirectBase = $protocol . '://' . $host; - if ($protocol === 'https' && $port !== '443') { - $redirectBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { + if ( + $port !== '' + && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) + ) { $redirectBase .= ':' . $port; } @@ -2140,26 +2114,8 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); - $pkceVerifier = ''; - if ($oauth2->usesPKCE()) { - $pkceVerifier = $oauth2->getPKCEVerifier(); - } - $loginURL = $oauth2->getLoginURL(); - if ($oauth2->usesPKCE()) { - $response->addCookie( - 'a_oauth2_pkce_' . $project->getId() . '_' . $provider, - $pkceVerifier, - \time() + 300, - '/', - Config::getParam('cookieDomain'), - ('https' === $protocol), - true, - Response::COOKIE_SAMESITE_LAX - ); - } - $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 712d4b7742..0e0fe8b821 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -11,10 +11,12 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\System\System; +use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; use Utopia\VCS\Adapter\Git\GitHub; @@ -219,6 +221,45 @@ Http::post('/v1/mock/api-key-unprefixed') ->dynamic($key, Response::MODEL_KEY); }); +Http::post('/v1/mock/tests/projects/:projectId/oauth2/x') + ->desc('Enable X OAuth2 provider for a project') + ->groups(['mock', 'api', 'projects']) + ->label('scope', 'public') + ->label('docs', false) + ->param('projectId', '', new UID(), 'Project ID.') + ->param('appId', '', new Text(256), 'Provider app ID.') + ->param('secret', '', new Text(512), 'Provider secret.') + ->param('enabled', true, new Boolean(), 'Provider enabled status.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->action(function (string $projectId, string $appId, string $secret, bool $enabled, Response $response, Database $dbForPlatform, Authorization $authorization) { + $isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development'; + + if (!$isDevelopment) { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); + } + + $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $providers = $project->getAttribute('oAuthProviders', []); + $providers['xAppid'] = $appId; + $providers['xSecret'] = $secret; + $providers['xEnabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'oAuthProviders' => $providers, + ]))); + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + Http::get('/v1/mock/github/callback') ->desc('Create installation document using GitHub installation id') ->groups(['mock', 'api', 'vcs']) diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 5e884b7bad..9358c89547 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -31,11 +31,6 @@ abstract class OAuth2 */ protected array $scopes; - /** - * @var string - */ - protected string $pkceVerifier = ''; - /** * OAuth2 constructor. * @@ -110,14 +105,6 @@ abstract class OAuth2 */ abstract public function getUserName(string $accessToken): string; - /** - * @return bool - */ - public function usesPKCE(): bool - { - return false; - } - /** * @param $scope * @@ -141,36 +128,6 @@ abstract class OAuth2 return $this->scopes; } - /** - * @return string - */ - public function getPKCEVerifier(): string - { - if (empty($this->pkceVerifier)) { - $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(32)); - } - - return $this->pkceVerifier; - } - - /** - * @param string $pkceVerifier - * - * @return void - */ - public function setPKCEVerifier(string $pkceVerifier): void - { - $this->pkceVerifier = $pkceVerifier; - } - - /** - * @return string - */ - protected function getPKCEChallenge(): string - { - return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); - } - /** * @param string $code * @@ -257,14 +214,4 @@ abstract class OAuth2 return (string)$response; } - - /** - * @param string $value - * - * @return string - */ - protected function base64UrlEncode(string $value): string - { - return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); - } } diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 122838078d..7ff16fcb78 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -34,6 +34,23 @@ class Etsy extends OAuth2 "profile_r", ]; + /** + * @var string + */ + private string $pkce = ''; + + /** + * @return string + */ + private function getPKCE(): string + { + if (empty($this->pkce)) { + $this->pkce = \bin2hex(\random_bytes(rand(43, 128))); + } + + return $this->pkce; + } + /** * @return string */ @@ -42,14 +59,6 @@ class Etsy extends OAuth2 return 'etsy'; } - /** - * @return bool - */ - public function usesPKCE(): bool - { - return true; - } - /** * @return string */ @@ -61,7 +70,7 @@ class Etsy extends OAuth2 'response_type' => 'code', 'state' => \json_encode($this->state), 'scope' => $this->scopes, - 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge' => $this->getPKCE(), 'code_challenge_method' => 'S256', ]); } @@ -85,7 +94,7 @@ class Etsy extends OAuth2 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'code' => $code, - 'code_verifier' => $this->getPKCEVerifier(), + 'code_verifier' => $this->getPKCE(), ]) ), true); } diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 31eeecbb1d..331e011270 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -38,17 +38,6 @@ class X extends OAuth2 return 'x'; } - /** - * @return bool - */ - public function usesPKCE(): bool - { - return true; - } - - /** - * @return string - */ public function getLoginURL(): string { return 'https://x.com/i/oauth2/authorize?' . \http_build_query([ @@ -57,8 +46,6 @@ class X extends OAuth2 'redirect_uri' => $this->callback, 'scope' => \implode(' ', $this->getScopes()), 'state' => \json_encode($this->state), - 'code_challenge' => $this->getPKCEChallenge(), - 'code_challenge_method' => 'S256', ]); } @@ -70,13 +57,6 @@ class X extends OAuth2 protected function getTokens(string $code): array { if (empty($this->tokens)) { - if (empty($this->pkceVerifier)) { - throw new Exception(\json_encode([ - 'error' => 'invalid_request', - 'error_description' => 'Missing PKCE verifier.', - ]), 400); - } - $headers = [ 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), 'Content-Type: application/x-www-form-urlencoded', @@ -91,7 +71,6 @@ class X extends OAuth2 'client_id' => $this->appID, 'grant_type' => 'authorization_code', 'redirect_uri' => $this->callback, - 'code_verifier' => $this->getPKCEVerifier(), ]) ), true); } From fe994703744c7a975b719505c4bb4f1584c46605 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Mon, 30 Mar 2026 16:09:42 +0530 Subject: [PATCH 06/11] revert test env change --- .env | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.env b/.env index 1b1a55d6f4..9abfa756e1 100644 --- a/.env +++ b/.env @@ -9,7 +9,7 @@ _APP_CONSOLE_WHITELIST_EMAILS= _APP_CONSOLE_SESSION_ALERTS=enabled _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_COUNTRIES_DENYLIST=AQ -_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io,posted-costumes-alphabetical-census.trycloudflare.com +_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io _APP_CONSOLE_SCHEMA=appwriteio _APP_MIGRATION_HOST=appwrite _APP_SYSTEM_EMAIL_NAME=Appwrite @@ -25,8 +25,8 @@ _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key _APP_DNS=172.16.238.100 # CoreDNS -_APP_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com -_APP_CONSOLE_DOMAIN=posted-costumes-alphabetical-census.trycloudflare.com +_APP_DOMAIN=appwrite.test +_APP_CONSOLE_DOMAIN=localhost _APP_CONSOLE_TRUSTED_PROJECTS=trusted-project,another-trusted-project _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost,rebranded.localhost @@ -143,6 +143,6 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10 _APP_PROJECT_REGIONS=default _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000 _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main -_APP_TRUSTED_HEADERS=x-forwarded-for,x-forwarded-proto,x-forwarded-host,x-forwarded-port +_APP_TRUSTED_HEADERS=x-forwarded-for _APP_POOL_ADAPTER=stack _APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite From 9da4f19d4f742dcd01de90215cadae0d6cf918e1 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 1 Apr 2026 12:11:40 +0530 Subject: [PATCH 07/11] fix: pkce flow --- app/controllers/mock.php | 41 ------------ src/Appwrite/Auth/OAuth2/X.php | 118 ++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 42 deletions(-) diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 0e0fe8b821..712d4b7742 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -11,12 +11,10 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\System\System; -use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; use Utopia\VCS\Adapter\Git\GitHub; @@ -221,45 +219,6 @@ Http::post('/v1/mock/api-key-unprefixed') ->dynamic($key, Response::MODEL_KEY); }); -Http::post('/v1/mock/tests/projects/:projectId/oauth2/x') - ->desc('Enable X OAuth2 provider for a project') - ->groups(['mock', 'api', 'projects']) - ->label('scope', 'public') - ->label('docs', false) - ->param('projectId', '', new UID(), 'Project ID.') - ->param('appId', '', new Text(256), 'Provider app ID.') - ->param('secret', '', new Text(512), 'Provider secret.') - ->param('enabled', true, new Boolean(), 'Provider enabled status.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('authorization') - ->action(function (string $projectId, string $appId, string $secret, bool $enabled, Response $response, Database $dbForPlatform, Authorization $authorization) { - $isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development'; - - if (!$isDevelopment) { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - } - - $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $providers = $project->getAttribute('oAuthProviders', []); - $providers['xAppid'] = $appId; - $providers['xSecret'] = $secret; - $providers['xEnabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ - 'oAuthProviders' => $providers, - ]))); - - $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::get('/v1/mock/github/callback') ->desc('Create installation document using GitHub installation id') ->groups(['mock', 'api', 'vcs']) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 331e011270..8a1ab49ef2 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -3,6 +3,8 @@ namespace Appwrite\Auth\OAuth2; use Appwrite\Auth\OAuth2; +use Appwrite\OpenSSL\OpenSSL; +use Utopia\System\System; // Reference Material // https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code @@ -10,6 +12,8 @@ use Appwrite\Auth\OAuth2; class X extends OAuth2 { + private const PKCE_STATE_KEY = '_pkce'; + /** * @var array */ @@ -30,6 +34,11 @@ class X extends OAuth2 'offline.access', ]; + /** + * @var string + */ + private string $pkceVerifier = ''; + /** * @return string */ @@ -40,12 +49,17 @@ class X extends OAuth2 public function getLoginURL(): string { + $state = $this->state; + $state[self::PKCE_STATE_KEY] = $this->encryptPKCEVerifier($this->getPKCEVerifier()); + return 'https://x.com/i/oauth2/authorize?' . \http_build_query([ 'response_type' => 'code', 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'scope' => \implode(' ', $this->getScopes()), - 'state' => \json_encode($this->state), + 'state' => $this->base64UrlEncode(\json_encode($state, JSON_THROW_ON_ERROR)), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', ]); } @@ -71,6 +85,7 @@ class X extends OAuth2 'client_id' => $this->appID, 'grant_type' => 'authorization_code', 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), ]) ), true); } @@ -177,4 +192,105 @@ class X extends OAuth2 return $this->user; } + public function parseState(string $state) + { + $decoded = $this->base64UrlDecode($state); + if ($decoded === false) { + return null; + } + + $state = \json_decode($decoded, true); + + if (!\is_array($state)) { + return $state; + } + + $pkce = $state[self::PKCE_STATE_KEY] ?? null; + + if (\is_array($pkce)) { + $this->pkceVerifier = $this->decryptPKCEVerifier($pkce); + } + + unset($state[self::PKCE_STATE_KEY]); + + return $state; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(64)); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true)); + } + + private function encryptPKCEVerifier(string $verifier): array + { + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $key = $this->getPKCEStateKey(); + $tag = null; + + $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); + + return [ + 'data' => $this->base64UrlEncode($data), + 'iv' => \bin2hex($iv), + 'tag' => \bin2hex($tag), + ]; + } + + private function decryptPKCEVerifier(array $payload): string + { + $data = $payload['data'] ?? ''; + $iv = $payload['iv'] ?? ''; + $tag = $payload['tag'] ?? ''; + + if ($data === '' || $iv === '' || $tag === '') { + return ''; + } + + $decodedData = $this->base64UrlDecode($data); + $decodedIv = \hex2bin($iv); + $decodedTag = \hex2bin($tag); + + if ($decodedData === false || $decodedIv === false || $decodedTag === false) { + return ''; + } + + return OpenSSL::decrypt( + $decodedData, + OpenSSL::CIPHER_AES_128_GCM, + $this->getPKCEStateKey(), + OPENSSL_RAW_DATA, + $decodedIv, + $decodedTag + ) ?: ''; + } + + private function getPKCEStateKey(): string + { + return System::getEnv('_APP_OPENSSL_KEY_V1'); + } + + private function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $value): string|false + { + $padding = \strlen($value) % 4; + if ($padding > 0) { + $value .= \str_repeat('=', 4 - $padding); + } + + return \base64_decode(\strtr($value, '-_', '+/'), true); + } + } From 3f725c6be93a63b87454cd0845ad09d34930ed2f Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 17:44:49 +0530 Subject: [PATCH 08/11] changes --- app/controllers/api/account.php | 70 +++++++++++++++------------------ 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 72347eaf9d..8fcb2e6abe 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1341,13 +1341,12 @@ Http::get('/v1/account/sessions/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -1398,12 +1397,10 @@ Http::get('/v1/account/sessions/oauth2/:provider') 'token' => false, ], $scopes); - $loginURL = $oauth2->getLoginURL(); - $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') - ->redirect($loginURL); + ->redirect($oauth2->getLoginURL()); }); Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') @@ -1421,13 +1418,12 @@ Http::get('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -1458,13 +1454,12 @@ Http::post('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -1511,13 +1506,12 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForToken') ->inject('authorization') ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -2054,13 +2048,12 @@ Http::get('/v1/account/tokens/oauth2/:provider') ->inject('project') ->inject('platform') ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $callbackBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $callbackBase .= ':' . $port; } @@ -2090,13 +2083,12 @@ Http::get('/v1/account/tokens/oauth2/:provider') } $host = $platform['consoleHostname'] ?? ''; - $protocol = $request->getProtocol(); - $port = (string) $request->getPort(); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $redirectBase = $protocol . '://' . $host; - if ( - $port !== '' - && !(($protocol === 'https' && $port === '443') || ($protocol === 'http' && $port === '80')) - ) { + if ($protocol === 'https' && $port !== '443') { + $redirectBase .= ':' . $port; + } elseif ($protocol === 'http' && $port !== '80') { $redirectBase .= ':' . $port; } From e4d1178e714a78ef887675e1200ec5e6e9a4320f Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 17:56:37 +0530 Subject: [PATCH 09/11] simplified code --- src/Appwrite/Auth/OAuth2/X.php | 72 ++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index 8a1ab49ef2..e161cf41b4 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -71,15 +71,10 @@ class X extends OAuth2 protected function getTokens(string $code): array { if (empty($this->tokens)) { - $headers = [ - 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), - 'Content-Type: application/x-www-form-urlencoded', - ]; - - $this->tokens = \json_decode($this->request( + $this->tokens = $this->decodeJsonObject($this->request( 'POST', 'https://api.x.com/2/oauth2/token', - $headers, + $this->tokenEndpointHeaders(), \http_build_query([ 'code' => $code, 'client_id' => $this->appID, @@ -87,7 +82,7 @@ class X extends OAuth2 'redirect_uri' => $this->callback, 'code_verifier' => $this->getPKCEVerifier(), ]) - ), true); + )); } return $this->tokens; @@ -100,21 +95,16 @@ class X extends OAuth2 */ public function refreshTokens(string $refreshToken): array { - $headers = [ - 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), - 'Content-Type: application/x-www-form-urlencoded', - ]; - - $this->tokens = \json_decode($this->request( + $this->tokens = $this->decodeJsonObject($this->request( 'POST', 'https://api.x.com/2/oauth2/token', - $headers, + $this->tokenEndpointHeaders(), \http_build_query([ 'client_id' => $this->appID, 'refresh_token' => $refreshToken, 'grant_type' => 'refresh_token', ]) - ), true); + )); if (empty($this->tokens['refresh_token'])) { $this->tokens['refresh_token'] = $refreshToken; @@ -182,38 +172,62 @@ class X extends OAuth2 protected function getUser(string $accessToken): array { if (empty($this->user)) { - $this->user = \json_decode($this->request( + $this->user = $this->decodeJsonObject($this->request( 'GET', 'https://api.x.com/2/users/me?user.fields=confirmed_email', ['Authorization: Bearer ' . $accessToken] - ), true); + )); } return $this->user; } - public function parseState(string $state) + /** + * @return array|null + */ + public function parseState(string $state): ?array { $decoded = $this->base64UrlDecode($state); if ($decoded === false) { return null; } - $state = \json_decode($decoded, true); + $parsed = \json_decode($decoded, true); - if (!\is_array($state)) { - return $state; + if (!\is_array($parsed)) { + return null; } - $pkce = $state[self::PKCE_STATE_KEY] ?? null; + $pkce = $parsed[self::PKCE_STATE_KEY] ?? null; if (\is_array($pkce)) { $this->pkceVerifier = $this->decryptPKCEVerifier($pkce); } - unset($state[self::PKCE_STATE_KEY]); + unset($parsed[self::PKCE_STATE_KEY]); - return $state; + return $parsed; + } + + /** + * @return list + */ + private function tokenEndpointHeaders(): array + { + return [ + 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret), + 'Content-Type: application/x-www-form-urlencoded', + ]; + } + + /** + * @return array + */ + private function decodeJsonObject(string $json): array + { + $decoded = \json_decode($json, true); + + return \is_array($decoded) ? $decoded : []; } private function getPKCEVerifier(): string @@ -275,7 +289,13 @@ class X extends OAuth2 private function getPKCEStateKey(): string { - return System::getEnv('_APP_OPENSSL_KEY_V1'); + $key = System::getEnv('_APP_OPENSSL_KEY_V1', ''); + + if ($key === '') { + throw new \RuntimeException('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); + } + + return $key; } private function base64UrlEncode(string $value): string From e6cfedd34063f34495dc0197c188a9a26a15ffdd Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 18:27:36 +0530 Subject: [PATCH 10/11] addressed greptile comment --- src/Appwrite/Auth/OAuth2/X.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index e161cf41b4..a2c6f81312 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -252,6 +252,10 @@ class X extends OAuth2 $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); + if ($data === false || $tag === null) { + throw new \RuntimeException('Failed to encrypt PKCE verifier.'); + } + return [ 'data' => $this->base64UrlEncode($data), 'iv' => \bin2hex($iv), From 44a37e9e20d0065f3fa74056c4be4b0ffa9516e1 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Wed, 8 Apr 2026 18:41:42 +0530 Subject: [PATCH 11/11] Use Exception for X OAuth2 PKCE encryption errors Align with other OAuth2 adapters that throw base Exception for configuration and crypto failures instead of RuntimeException. Made-with: Cursor --- src/Appwrite/Auth/OAuth2/X.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php index a2c6f81312..d12ce25b33 100644 --- a/src/Appwrite/Auth/OAuth2/X.php +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -253,7 +253,7 @@ class X extends OAuth2 $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag); if ($data === false || $tag === null) { - throw new \RuntimeException('Failed to encrypt PKCE verifier.'); + throw new \Exception('Failed to encrypt PKCE verifier.'); } return [ @@ -296,7 +296,7 @@ class X extends OAuth2 $key = System::getEnv('_APP_OPENSSL_KEY_V1', ''); if ($key === '') { - throw new \RuntimeException('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); + throw new \Exception('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); } return $key;