Mercurial > pidgin
comparison libpurple/protocols/oscar/clientlogin.c @ 27161:7054f810b0f9
Check in code that connects to oscar using clientLogin. This is the
authentication scheme they've publically documented. We still use
the old MD5-style login as the default, but you can optionally try
this out by checking a check box on the advanced tab of your oscar
account.
Functionally everything is supposed to be the same. However, for
some reason users with Mobile IM forwarding turned on don't show
up online and can't be messaged. Not sure why.
Using clientLogin DOES make it easier for AOL to track us. And yes,
it probably makes it easier for AOL to block us, too. But I don't
believe they want to do that. I believe they're trying to keep their
network open, and I think we should appreciate that and try to work
with them. We're not just some small open source project that slips
under the radar unnoticed anymore.
It's good to have options, right?
None of this code was taken from anywhere (outside of libpurple). I
wrote it all from scratch (and took a few bits from other places in
libpurple). I did use the documentation on http://dev.aol.com/aim ,
but I don't believe that affects us from a licensing standpoint in
any way. If you disagree we should talk about it on the devel
mailing list.
author | Mark Doliner <mark@kingant.net> |
---|---|
date | Tue, 23 Jun 2009 18:20:12 +0000 |
parents | |
children | 1a255e11c02b |
comparison
equal
deleted
inserted
replaced
27160:763247959e00 | 27161:7054f810b0f9 |
---|---|
1 /* | |
2 * Purple's oscar protocol plugin | |
3 * This file is the legal property of its developers. | |
4 * Please see the AUTHORS file distributed alongside this file. | |
5 * | |
6 * This library is free software; you can redistribute it and/or | |
7 * modify it under the terms of the GNU Lesser General Public | |
8 * License as published by the Free Software Foundation; either | |
9 * version 2 of the License, or (at your option) any later version. | |
10 * | |
11 * This library is distributed in the hope that it will be useful, | |
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |
14 * Lesser General Public License for more details. | |
15 * | |
16 * You should have received a copy of the GNU Lesser General Public | |
17 * License along with this library; if not, write to the Free Software | |
18 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02111-1301 USA | |
19 */ | |
20 | |
21 /** | |
22 * This file implements AIM's clientLogin procedure for authenticating | |
23 * users. This replaces the older MD5-based and XOR-based | |
24 * authentication methods that use SNAC family 0x0017. | |
25 * | |
26 * This doesn't use SNACs or FLAPs at all. It makes http and https | |
27 * POSTs to AOL to validate the user based on the password they | |
28 * provided to us. Upon successful authentication we request a | |
29 * connection to the BOS server by calling startOSCARsession. The | |
30 * AOL server gives us the hostname and port number to use, as well | |
31 * as the cookie to use to authenticate to the BOS server. And then | |
32 * everything else is the same as with BUCP. | |
33 * | |
34 * For details, see: | |
35 * http://dev.aol.com/aim/oscar/#AUTH | |
36 * http://dev.aol.com/authentication_for_clients | |
37 */ | |
38 | |
39 #include "cipher.h" | |
40 #include "core.h" | |
41 | |
42 #include "oscar.h" | |
43 | |
44 #define URL_CLIENT_LOGIN "https://api.screenname.aol.com/auth/clientLogin" | |
45 #define URL_START_OSCAR_SESSION "http://api.oscar.aol.com/aim/startOSCARSession" | |
46 | |
47 /* | |
48 * Using clientLogin requires a developer ID. This dev ID is owned by | |
49 * the AIM account "markdoliner" | |
50 */ | |
51 #define CLIENT_KEY "ma15d7JTxbmVG-RP" | |
52 | |
53 /** | |
54 * This is similar to purple_url_encode() except that it follows | |
55 * RFC3986 a little more closely by not encoding - . _ and ~ | |
56 * It also uses capital letters as hex characters because capital | |
57 * letters are required by AOL. The RFC says that capital letters | |
58 * are a SHOULD and that URLs that use capital letters are | |
59 * equivalent to URLs that use small letters. | |
60 * | |
61 * TODO: Check if purple_url_encode() can be replaced with this | |
62 * version without breaking anything. | |
63 */ | |
64 static const char *oscar_auth_url_encode(const char *str) | |
65 { | |
66 const char *iter; | |
67 static char buf[BUF_LEN]; | |
68 char utf_char[6]; | |
69 guint i, j = 0; | |
70 | |
71 g_return_val_if_fail(str != NULL, NULL); | |
72 g_return_val_if_fail(g_utf8_validate(str, -1, NULL), NULL); | |
73 | |
74 iter = str; | |
75 for (; *iter && j < (BUF_LEN - 1) ; iter = g_utf8_next_char(iter)) { | |
76 gunichar c = g_utf8_get_char(iter); | |
77 /* If the character is an ASCII character and is alphanumeric | |
78 * no need to escape */ | |
79 if ((c < 128 && isalnum(c)) || c =='-' || c == '.' || c == '_' || c == '~') { | |
80 buf[j++] = c; | |
81 } else { | |
82 int bytes = g_unichar_to_utf8(c, utf_char); | |
83 for (i = 0; i < bytes; i++) { | |
84 if (j > (BUF_LEN - 4)) | |
85 break; | |
86 sprintf(buf + j, "%%%02X", utf_char[i] & 0xff); | |
87 j += 3; | |
88 } | |
89 } | |
90 } | |
91 | |
92 buf[j] = '\0'; | |
93 | |
94 return buf; | |
95 } | |
96 | |
97 /** | |
98 * @return A null-terminated base64 encoded version of the HMAC | |
99 * calculated using the given key and data. | |
100 */ | |
101 static gchar *hmac_sha256(const char *key, const char *message) | |
102 { | |
103 PurpleCipherContext *context; | |
104 guchar digest[32]; | |
105 | |
106 context = purple_cipher_context_new_by_name("hmac", NULL); | |
107 purple_cipher_context_set_option(context, "hash", "sha256"); | |
108 purple_cipher_context_set_key(context, (guchar *)key); | |
109 purple_cipher_context_append(context, (guchar *)message, strlen(message)); | |
110 purple_cipher_context_digest(context, sizeof(digest), digest, NULL); | |
111 purple_cipher_context_destroy(context); | |
112 | |
113 return purple_base64_encode(digest, sizeof(digest)); | |
114 } | |
115 | |
116 /** | |
117 * @return A base-64 encoded HMAC-SHA256 signature created using the | |
118 * technique documented at | |
119 * http://dev.aol.com/authentication_for_clients#signing | |
120 */ | |
121 static gchar *generate_signature(const char *method, const char *url, const char *parameters, const char *session_key) | |
122 { | |
123 char *encoded_url, *signature_base_string, *signature; | |
124 const char *encoded_parameters; | |
125 | |
126 encoded_url = g_strdup(oscar_auth_url_encode(url)); | |
127 encoded_parameters = oscar_auth_url_encode(parameters); | |
128 signature_base_string = g_strdup_printf("%s&%s&%s", | |
129 method, encoded_url, encoded_parameters); | |
130 g_free(encoded_url); | |
131 | |
132 signature = hmac_sha256(session_key, signature_base_string); | |
133 g_free(signature_base_string); | |
134 | |
135 return signature; | |
136 } | |
137 | |
138 static gboolean parse_start_oscar_session_response(PurpleConnection *gc, const gchar *response, gsize response_len, char **host, unsigned short *port, char **cookie) | |
139 { | |
140 xmlnode *response_node, *tmp_node, *data_node; | |
141 xmlnode *host_node, *port_node, *cookie_node; | |
142 char *tmp; | |
143 | |
144 /* Parse the response as XML */ | |
145 response_node = xmlnode_from_str(response, response_len); | |
146 if (response_node == NULL) | |
147 { | |
148 purple_debug_error("oscar", "startOSCARSession could not parse " | |
149 "response as XML: %s\n", response); | |
150 purple_connection_error_reason(gc, | |
151 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
152 _("Received unexpected response from " URL_START_OSCAR_SESSION)); | |
153 return FALSE; | |
154 } | |
155 | |
156 /* Grab the necessary XML nodes */ | |
157 tmp_node = xmlnode_get_child(response_node, "statusCode"); | |
158 data_node = xmlnode_get_child(response_node, "data"); | |
159 if (data_node != NULL) { | |
160 host_node = xmlnode_get_child(data_node, "host"); | |
161 port_node = xmlnode_get_child(data_node, "port"); | |
162 cookie_node = xmlnode_get_child(data_node, "cookie"); | |
163 } | |
164 | |
165 /* Make sure we have a status code */ | |
166 if (tmp_node == NULL || (tmp = xmlnode_get_data_unescaped(tmp_node)) == NULL) { | |
167 purple_debug_error("oscar", "startOSCARSession response was " | |
168 "missing statusCode: %s\n", response); | |
169 purple_connection_error_reason(gc, | |
170 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
171 _("Received unexpected response from " URL_START_OSCAR_SESSION)); | |
172 xmlnode_free(response_node); | |
173 return FALSE; | |
174 } | |
175 | |
176 /* Make sure the status code was 200 */ | |
177 if (strcmp(tmp, "200") != 0) | |
178 { | |
179 purple_debug_error("oscar", "startOSCARSession response statusCode " | |
180 "was %s: %s\n", tmp, response); | |
181 | |
182 if (strcmp(tmp, "401") == 0) | |
183 purple_connection_error_reason(gc, | |
184 PURPLE_CONNECTION_ERROR_OTHER_ERROR, | |
185 _("You have been connecting and disconnecting too " | |
186 "frequently. Wait ten minutes and try again. If " | |
187 "you continue to try, you will need to wait even " | |
188 "longer.")); | |
189 else | |
190 purple_connection_error_reason(gc, | |
191 PURPLE_CONNECTION_ERROR_OTHER_ERROR, | |
192 _("Received unexpected response from " URL_START_OSCAR_SESSION)); | |
193 | |
194 g_free(tmp); | |
195 xmlnode_free(response_node); | |
196 return FALSE; | |
197 } | |
198 g_free(tmp); | |
199 | |
200 /* Make sure we have everything else */ | |
201 if (data_node == NULL || host_node == NULL || | |
202 port_node == NULL || cookie_node == NULL) | |
203 { | |
204 purple_debug_error("oscar", "startOSCARSession response was missing " | |
205 "something: %s\n", response); | |
206 purple_connection_error_reason(gc, | |
207 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
208 _("Received unexpected response from " URL_START_OSCAR_SESSION)); | |
209 xmlnode_free(response_node); | |
210 return FALSE; | |
211 } | |
212 | |
213 /* Extract data from the XML */ | |
214 *host = xmlnode_get_data_unescaped(host_node); | |
215 tmp = xmlnode_get_data_unescaped(port_node); | |
216 *cookie = xmlnode_get_data_unescaped(cookie_node); | |
217 if (*host == NULL || **host == '\0' || tmp == NULL || *tmp == '\0' || cookie == NULL || *cookie == '\0') | |
218 { | |
219 purple_debug_error("oscar", "startOSCARSession response was missing " | |
220 "something: %s\n", response); | |
221 purple_connection_error_reason(gc, | |
222 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
223 _("Received unexpected response from " URL_START_OSCAR_SESSION)); | |
224 g_free(*host); | |
225 g_free(tmp); | |
226 g_free(*cookie); | |
227 xmlnode_free(response_node); | |
228 return FALSE; | |
229 } | |
230 | |
231 *port = atoi(tmp); | |
232 g_free(tmp); | |
233 | |
234 return TRUE; | |
235 } | |
236 | |
237 static void start_oscar_session_cb(PurpleUtilFetchUrlData *url_data, gpointer user_data, const gchar *url_text, gsize len, const gchar *error_message) | |
238 { | |
239 OscarData *od; | |
240 PurpleConnection *gc; | |
241 char *host, *cookie; | |
242 unsigned short port; | |
243 guint8 *cookiedata; | |
244 gsize cookiedata_len; | |
245 | |
246 od = user_data; | |
247 gc = od->gc; | |
248 | |
249 od->url_data = NULL; | |
250 | |
251 if (error_message != NULL || len == 0) { | |
252 gchar *tmp; | |
253 tmp = g_strdup_printf(_("Error requesting " URL_START_OSCAR_SESSION | |
254 ": %s"), error_message); | |
255 purple_connection_error_reason(gc, | |
256 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, tmp); | |
257 g_free(tmp); | |
258 return; | |
259 } | |
260 | |
261 if (!parse_start_oscar_session_response(gc, url_text, len, &host, &port, &cookie)) | |
262 return; | |
263 | |
264 cookiedata = purple_base64_decode(cookie, &cookiedata_len); | |
265 oscar_connect_to_bos(gc, od, host, port, cookiedata, cookiedata_len); | |
266 g_free(cookiedata); | |
267 | |
268 g_free(host); | |
269 g_free(cookie); | |
270 } | |
271 | |
272 static void send_start_oscar_session(OscarData *od, const char *token, const char *session_key, time_t hosttime) | |
273 { | |
274 char *query_string, *signature, *url; | |
275 | |
276 /* Construct the GET parameters */ | |
277 query_string = g_strdup_printf("a=%s" | |
278 "&f=xml" | |
279 "&k=" CLIENT_KEY | |
280 "&ts=%zu" | |
281 "&useTLS=0", | |
282 oscar_auth_url_encode(token), hosttime); | |
283 signature = generate_signature("GET", URL_START_OSCAR_SESSION, | |
284 query_string, session_key); | |
285 url = g_strdup_printf(URL_START_OSCAR_SESSION "?%s&sig_sha256=%s", | |
286 query_string, signature); | |
287 g_free(query_string); | |
288 g_free(signature); | |
289 | |
290 /* Make the request */ | |
291 od->url_data = purple_util_fetch_url(url, TRUE, NULL, FALSE, | |
292 start_oscar_session_cb, od); | |
293 g_free(url); | |
294 } | |
295 | |
296 /** | |
297 * This function parses the given response from a clientLogin request | |
298 * and extracts the useful information. | |
299 * | |
300 * @param gc The PurpleConnection. If the response data does | |
301 * not indicate then purple_connection_error_reason() | |
302 * will be called to close this connection. | |
303 * @param response The response data from the clientLogin request. | |
304 * @param response_len The length of the above response, or -1 if | |
305 * @response is NUL terminated. | |
306 * @param token If parsing was successful then this will be set to | |
307 * a newly allocated string containing the token. The | |
308 * caller should g_free this string when it is finished | |
309 * with it. On failure this value will be untouched. | |
310 * @param secret If parsing was successful then this will be set to | |
311 * a newly allocated string containing the secret. The | |
312 * caller should g_free this string when it is finished | |
313 * with it. On failure this value will be untouched. | |
314 * @param hosttime If parsing was successful then this will be set to | |
315 * the time on the OpenAuth Server in seconds since the | |
316 * Unix epoch. On failure this value will be untouched. | |
317 * | |
318 * @return TRUE if the request was successful and we were able to | |
319 * extract all info we need. Otherwise FALSE. | |
320 */ | |
321 static gboolean parse_client_login_response(PurpleConnection *gc, const gchar *response, gsize response_len, char **token, char **secret, time_t *hosttime) | |
322 { | |
323 xmlnode *response_node, *tmp_node, *data_node; | |
324 xmlnode *secret_node, *hosttime_node, *token_node, *tokena_node; | |
325 char *tmp; | |
326 | |
327 /* Parse the response as XML */ | |
328 response_node = xmlnode_from_str(response, response_len); | |
329 if (response_node == NULL) | |
330 { | |
331 purple_debug_error("oscar", "clientLogin could not parse " | |
332 "response as XML: %s\n", response); | |
333 purple_connection_error_reason(gc, | |
334 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
335 _("Received unexpected response from " URL_CLIENT_LOGIN)); | |
336 return FALSE; | |
337 } | |
338 | |
339 /* Grab the necessary XML nodes */ | |
340 tmp_node = xmlnode_get_child(response_node, "statusCode"); | |
341 data_node = xmlnode_get_child(response_node, "data"); | |
342 if (data_node != NULL) { | |
343 secret_node = xmlnode_get_child(data_node, "sessionSecret"); | |
344 hosttime_node = xmlnode_get_child(data_node, "hostTime"); | |
345 token_node = xmlnode_get_child(data_node, "token"); | |
346 if (token_node != NULL) | |
347 tokena_node = xmlnode_get_child(token_node, "a"); | |
348 } | |
349 | |
350 /* Make sure we have a status code */ | |
351 if (tmp_node == NULL || (tmp = xmlnode_get_data_unescaped(tmp_node)) == NULL) { | |
352 purple_debug_error("oscar", "clientLogin response was " | |
353 "missing statusCode: %s\n", response); | |
354 purple_connection_error_reason(gc, | |
355 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
356 _("Received unexpected response from " URL_CLIENT_LOGIN)); | |
357 xmlnode_free(response_node); | |
358 return FALSE; | |
359 } | |
360 | |
361 /* Make sure the status code was 200 */ | |
362 if (strcmp(tmp, "200") != 0) | |
363 { | |
364 int status_code, status_detail_code = 0; | |
365 | |
366 status_code = atoi(tmp); | |
367 g_free(tmp); | |
368 tmp_node = xmlnode_get_child(response_node, "statusDetailCode"); | |
369 if (tmp_node != NULL && (tmp = xmlnode_get_data_unescaped(tmp_node)) != NULL) { | |
370 status_detail_code = atoi(tmp); | |
371 g_free(tmp); | |
372 } | |
373 | |
374 purple_debug_error("oscar", "clientLogin response statusCode " | |
375 "was %d (%d): %s\n", status_code, status_detail_code, response); | |
376 | |
377 if (status_code == 330 && status_detail_code == 3011) { | |
378 purple_connection_error_reason(gc, | |
379 PURPLE_CONNECTION_ERROR_AUTHENTICATION_FAILED, | |
380 _("Incorrect password.")); | |
381 } else if (status_code == 401 && status_detail_code == 3019) { | |
382 purple_connection_error_reason(gc, | |
383 PURPLE_CONNECTION_ERROR_OTHER_ERROR, | |
384 _("AOL does not allow your screen name to authenticate via this site.")); | |
385 } else | |
386 purple_connection_error_reason(gc, | |
387 PURPLE_CONNECTION_ERROR_OTHER_ERROR, | |
388 _("Received unexpected response from " URL_CLIENT_LOGIN)); | |
389 | |
390 xmlnode_free(response_node); | |
391 return FALSE; | |
392 } | |
393 g_free(tmp); | |
394 | |
395 /* Make sure we have everything else */ | |
396 if (data_node == NULL || secret_node == NULL || | |
397 token_node == NULL || tokena_node == NULL) | |
398 { | |
399 purple_debug_error("oscar", "clientLogin response was missing " | |
400 "something: %s\n", response); | |
401 purple_connection_error_reason(gc, | |
402 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
403 _("Received unexpected response from " URL_CLIENT_LOGIN)); | |
404 xmlnode_free(response_node); | |
405 return FALSE; | |
406 } | |
407 | |
408 /* Extract data from the XML */ | |
409 *token = xmlnode_get_data_unescaped(tokena_node); | |
410 *secret = xmlnode_get_data_unescaped(secret_node); | |
411 tmp = xmlnode_get_data_unescaped(hosttime_node); | |
412 if (*token == NULL || **token == '\0' || *secret == NULL || **secret == '\0' || tmp == NULL || *tmp == '\0') | |
413 { | |
414 purple_debug_error("oscar", "clientLogin response was missing " | |
415 "something: %s\n", response); | |
416 purple_connection_error_reason(gc, | |
417 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, | |
418 _("Received unexpected response from " URL_CLIENT_LOGIN)); | |
419 g_free(*token); | |
420 g_free(*secret); | |
421 g_free(tmp); | |
422 xmlnode_free(response_node); | |
423 return FALSE; | |
424 } | |
425 | |
426 *hosttime = strtol(tmp, NULL, 10); | |
427 g_free(tmp); | |
428 | |
429 xmlnode_free(response_node); | |
430 | |
431 return TRUE; | |
432 } | |
433 | |
434 static void client_login_cb(PurpleUtilFetchUrlData *url_data, gpointer user_data, const gchar *url_text, gsize len, const gchar *error_message) | |
435 { | |
436 OscarData *od; | |
437 PurpleConnection *gc; | |
438 char *token, *secret, *session_key; | |
439 time_t hosttime; | |
440 int password_len; | |
441 char *password; | |
442 | |
443 od = user_data; | |
444 gc = od->gc; | |
445 | |
446 od->url_data = NULL; | |
447 | |
448 if (error_message != NULL || len == 0) { | |
449 gchar *tmp; | |
450 tmp = g_strdup_printf(_("Error requesting " URL_CLIENT_LOGIN | |
451 ": %s"), error_message); | |
452 purple_connection_error_reason(gc, | |
453 PURPLE_CONNECTION_ERROR_NETWORK_ERROR, tmp); | |
454 g_free(tmp); | |
455 return; | |
456 } | |
457 | |
458 if (!parse_client_login_response(gc, url_text, len, &token, &secret, &hosttime)) | |
459 return; | |
460 | |
461 password_len = strlen(purple_connection_get_password(gc)); | |
462 password = g_strdup_printf("%.*s", | |
463 od->icq ? MIN(password_len, MAXICQPASSLEN) : password_len, | |
464 purple_connection_get_password(gc)); | |
465 session_key = hmac_sha256(password, secret); | |
466 g_free(password); | |
467 g_free(secret); | |
468 | |
469 send_start_oscar_session(od, token, session_key, hosttime); | |
470 | |
471 g_free(token); | |
472 g_free(session_key); | |
473 } | |
474 | |
475 /** | |
476 * This function sends a request to | |
477 * https://api.screenname.aol.com/auth/clientLogin with the user's | |
478 * username and password and receives the user's session key, which is | |
479 * used to request a connection to the BOSS server. | |
480 */ | |
481 void send_client_login(OscarData *od, const char *username) | |
482 { | |
483 PurpleConnection *gc; | |
484 GString *request, *body; | |
485 const char *tmp; | |
486 char *password; | |
487 int password_len; | |
488 | |
489 gc = od->gc; | |
490 | |
491 /* | |
492 * We truncate ICQ passwords to 8 characters. There is probably a | |
493 * limit for AIM passwords, too, but we really only need to do | |
494 * this for ICQ because older ICQ clients let you enter a password | |
495 * as long as you wanted and then they truncated it silently. | |
496 * | |
497 * And we can truncate based on the number of bytes and not the | |
498 * number of characters because passwords for AIM and ICQ are | |
499 * supposed to be plain ASCII (I don't know if this has always been | |
500 * the case, though). | |
501 */ | |
502 tmp = purple_connection_get_password(gc); | |
503 password_len = strlen(tmp); | |
504 password = g_strndup(tmp, od->icq ? MIN(password_len, MAXICQPASSLEN) : password_len); | |
505 | |
506 /* Construct the body of the HTTP POST request */ | |
507 body = g_string_new(""); | |
508 g_string_append_printf(body, "devId=" CLIENT_KEY); | |
509 g_string_append_printf(body, "&f=xml"); | |
510 g_string_append_printf(body, "&pwd=%s", oscar_auth_url_encode(password)); | |
511 g_string_append_printf(body, "&s=%s", oscar_auth_url_encode(username)); | |
512 g_free(password); | |
513 | |
514 /* Construct an HTTP POST request */ | |
515 request = g_string_new("POST /auth/clientLogin HTTP/1.0\r\n" | |
516 "Connection: close\r\n" | |
517 "Accept: */*\r\n"); | |
518 | |
519 /* Tack on the body */ | |
520 g_string_append_printf(request, "Content-Type: application/x-www-form-urlencoded; charset=UTF-8\r\n"); | |
521 g_string_append_printf(request, "Content-Length: %lu\r\n\r\n", body->len); | |
522 g_string_append_len(request, body->str, body->len); | |
523 g_string_free(body, TRUE); | |
524 | |
525 /* Send the POST request */ | |
526 od->url_data = purple_util_fetch_url_request(URL_CLIENT_LOGIN, | |
527 TRUE, NULL, FALSE, request->str, FALSE, | |
528 client_login_cb, od); | |
529 g_string_free(request, TRUE); | |
530 } |