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 8eb49ea27b..67588ffd5d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2185,7 +2185,7 @@ Http::get('/v1/account/tokens/oauth2/:provider') } $host = $platform['consoleHostname'] ?? ''; - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $redirectBase = $protocol . '://' . $host; if ($protocol === 'https' && $port !== '443') { @@ -2208,10 +2208,12 @@ Http::get('/v1/account/tokens/oauth2/:provider') 'token' => true, ], $scopes); + $loginURL = $oauth2->getLoginURL(); + $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..d12ce25b33 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/X.php @@ -0,0 +1,320 @@ +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' => $this->base64UrlEncode(\json_encode($state, JSON_THROW_ON_ERROR)), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $this->tokens = $this->decodeJsonObject($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $this->tokenEndpointHeaders(), + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + ]) + )); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $this->tokens = $this->decodeJsonObject($this->request( + 'POST', + 'https://api.x.com/2/oauth2/token', + $this->tokenEndpointHeaders(), + \http_build_query([ + 'client_id' => $this->appID, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]) + )); + + 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 = $this->decodeJsonObject($this->request( + 'GET', + 'https://api.x.com/2/users/me?user.fields=confirmed_email', + ['Authorization: Bearer ' . $accessToken] + )); + } + + return $this->user; + } + + /** + * @return array|null + */ + public function parseState(string $state): ?array + { + $decoded = $this->base64UrlDecode($state); + if ($decoded === false) { + return null; + } + + $parsed = \json_decode($decoded, true); + + if (!\is_array($parsed)) { + return null; + } + + $pkce = $parsed[self::PKCE_STATE_KEY] ?? null; + + if (\is_array($pkce)) { + $this->pkceVerifier = $this->decryptPKCEVerifier($pkce); + } + + unset($parsed[self::PKCE_STATE_KEY]); + + 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 + { + 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); + + if ($data === false || $tag === null) { + throw new \Exception('Failed to encrypt PKCE verifier.'); + } + + 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 + { + $key = System::getEnv('_APP_OPENSSL_KEY_V1', ''); + + if ($key === '') { + throw new \Exception('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.'); + } + + return $key; + } + + 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); + } + +}