]> diplodocus.org Git - nmh/blobdiff - sbr/oauth.c
mhbuildsbr.c: Flip logic, moving goto to then-block; no need for else.
[nmh] / sbr / oauth.c
old mode 100644 (file)
new mode 100755 (executable)
index b795eb8..8e3d6b0
@@ -1,4 +1,5 @@
-/*
+/* oauth.c -- OAuth 2.0 implementation for XOAUTH2 in SMTP and POP3.
+ *
  * This code is Copyright (c) 2014, by the authors of nmh.  See the
  * COPYRIGHT file in the root directory of the nmh distribution for
  * complete copyright information.
 
 #include <h/oauth.h>
 #include <h/utils.h>
+#include "lock_file.h"
 
 #define JSON_TYPE "application/json"
 
-/* We pretend access tokens expire 30 seconds earlier than they actually do to
+/* We pretend access tokens expire 60 seconds earlier than they actually do to
  * allow for separate processes to use and refresh access tokens.  The process
  * that uses the access token (post) has an error if the token is expired; the
  * process that refreshes the access token (send) must have already refreshed if
  * the expiration is close.
  *
- * 30s is arbitrary, and hopefully is enough to allow for clock skew.
+ * 60s is arbitrary, and hopefully is enough to allow for clock skew.
  * Currently only Gmail supports XOAUTH2, and seems to always use a token
  * life-time of 3600s, but that is not guaranteed.  It is possible for Gmail to
  * issue an access token with a life-time so short that even after send
@@ -44,7 +46,7 @@
  * (not counting header and not null-terminated) */
 #define RESPONSE_BODY_MAX 8192
 
-/* Maxium size for URLs and URI-encoded query strings, null-terminated.
+/* Maximum size for URLs and URI-encoded query strings, null-terminated.
  *
  * Actual maximum we need is based on the size of tokens (limited by
  * RESPONSE_BODY_MAX), code user copies from a web page (arbitrarily large), and
  */
 #define URL_MAX 8192
 
-struct service_info {
-    /* Name of service, so we can search static SERVICES (below) and for
-     * determining default credential file name. */
-    char *name;
-
-    /* Human-readable name of the service; in mh_oauth_ctx::svc this is not
-     * another buffer to free, but a pointer to either static SERVICE data
-     * (below) or to the name field. */
-    char *display_name;
-
-    /* [1] 2.2 Client Identifier, 2.3.1 Client Password */
-    char *client_id;
-    /* [1] 2.3.1 Client Password */
-    char *client_secret;
-    /* [1] 3.1 Authorization Endpoint */
-    char *auth_endpoint;
-    /* [1] 3.1.2 Redirection Endpoint */
-    char *redirect_uri;
-    /* [1] 3.2 Token Endpoint */
-    char *token_endpoint;
-    /* [1] 3.3 Access Token Scope */
-    char *scope;
-};
-
-static const struct service_info SERVICES[] = {
-    /* https://developers.google.com/accounts/docs/OAuth2InstalledApp */
-    {
-        /* name */ "gmail",
-        /* display_name */ "Gmail",
-
-        /* client_id */ "91584523849-8lv9kgp1rvp8ahta6fa4b125tn2polcg.apps.googleusercontent.com",
-        /* client_secret */ "Ua8sX34xyv7hVrKM-U70dKI6",
-
-        /* auth_endpoint */ "https://accounts.google.com/o/oauth2/auth",
-        /* redirect_uri */ "urn:ietf:wg:oauth:2.0:oob",
-        /* token_endpoint */ "https://accounts.google.com/o/oauth2/token",
-        /* scope */ "https://mail.google.com/"
-    }
-};
-
 struct mh_oauth_cred {
     mh_oauth_ctx *ctx;
 
@@ -108,10 +70,13 @@ struct mh_oauth_cred {
     /* Ignoring token_type ([1] 7.1) because
      * https://developers.google.com/accounts/docs/OAuth2InstalledApp says
      * "Currently, this field always has the value Bearer". */
+
+    /* only filled while loading cred files, otherwise NULL */
+    char *user;
 };
 
 struct mh_oauth_ctx {
-    struct service_info svc;
+    struct mh_oauth_service_info svc;
     CURL *curl;
     FILE *log;
 
@@ -164,23 +129,23 @@ static boolean get_json_strings(const char *, size_t, FILE *, ...);
 static boolean make_query_url(char *, size_t, CURL *, const char *, ...);
 static boolean post(struct curl_ctx *, const char *, const char *);
 
-char *
-mh_oauth_do_xoauth(const char *user, const char *svc, FILE *log)
+int
+mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res,
+                  size_t *oauth_res_len, FILE *log)
 {
     mh_oauth_ctx *ctx;
     mh_oauth_cred *cred;
     char *fn;
     int failed_to_lock = 0;
     FILE *fp;
-    size_t client_res_len;
     char *client_res;
-    char *client_res_b64;
 
-    if (!mh_oauth_new (&ctx, svc)) adios(NULL, mh_oauth_get_err_string(ctx));
+    if (!mh_oauth_new (&ctx, svc))
+        adios(NULL, "%s", mh_oauth_get_err_string(ctx));
 
     if (log != NULL) mh_oauth_log_to(stderr, ctx);
 
-    fn = getcpy(mh_oauth_cred_fn(ctx));
+    fn = mh_xstrdup(mh_oauth_cred_fn(svc));
     fp = lkfopendata(fn, "r+", &failed_to_lock);
     if (fp == NULL) {
         if (errno == ENOENT) {
@@ -192,8 +157,8 @@ mh_oauth_do_xoauth(const char *user, const char *svc, FILE *log)
         adios(fn, "failed to lock");
     }
 
-    if ((cred = mh_oauth_cred_load(fp, ctx)) == NULL) {
-        adios(NULL, mh_oauth_get_err_string(ctx));
+    if ((cred = mh_oauth_cred_load(fp, ctx, user)) == NULL) {
+        adios(NULL, "%s", mh_oauth_get_err_string(ctx));
     }
 
     if (!mh_oauth_access_token_valid(time(NULL), cred)) {
@@ -204,13 +169,13 @@ mh_oauth_do_xoauth(const char *user, const char *svc, FILE *log)
             if (mh_oauth_get_err_code(ctx) == MH_OAUTH_BAD_GRANT) {
                 adios(NULL, "credentials rejected -- run mhlogin -saslmech xoauth2 -authservice %s", svc);
             }
-            advise(NULL, "error refreshing OAuth2 token");
-            adios(NULL, mh_oauth_get_err_string(ctx));
+            inform("error refreshing OAuth2 token");
+            adios(NULL, "%s", mh_oauth_get_err_string(ctx));
         }
 
         fseek(fp, 0, SEEK_SET);
-        if (!mh_oauth_cred_save(fp, cred)) {
-            adios(NULL, mh_oauth_get_err_string(ctx));
+        if (!mh_oauth_cred_save(fp, cred, user)) {
+            adios(NULL, "%s", mh_oauth_get_err_string(ctx));
         }
     }
 
@@ -220,25 +185,21 @@ mh_oauth_do_xoauth(const char *user, const char *svc, FILE *log)
     free(fn);
 
     /* XXX writeBase64raw modifies the source buffer!  make a copy */
-    client_res = getcpy(mh_oauth_sasl_client_response(&client_res_len, user,
+    client_res = mh_xstrdup(mh_oauth_sasl_client_response(oauth_res_len, user,
                                                       cred));
     mh_oauth_cred_free(cred);
     mh_oauth_free(ctx);
-    client_res_b64 = mh_xmalloc(((((client_res_len) + 2) / 3 ) * 4) + 1);
-    if (writeBase64raw((unsigned char *)client_res, client_res_len,
-                       (unsigned char *)client_res_b64) != OK) {
-        adios(NULL, "base64 encoding of XOAUTH2 client response failed");
-    }
-    free(client_res);
 
-    return client_res_b64;
+    *oauth_res = (unsigned char *) client_res;
+
+    return OK;
 }
 
 static boolean
 is_json(const char *content_type)
 {
     return content_type != NULL
-        && strncasecmp(content_type, JSON_TYPE, sizeof JSON_TYPE - 1) == 0;
+        && strncasecmp(content_type, JSON_TYPE, LEN(JSON_TYPE)) == 0;
 }
 
 static void
@@ -262,7 +223,7 @@ set_err_http(mh_oauth_ctx *ctx, const struct curl_ctx *curl_ctx)
     /* 5.2. Error Response says error response should use status code 400 and
      * application/json body.  If Content-Type matches, try to parse the body
      * regardless of the status code. */
-    if (curl_ctx->res_body != NULL
+    if (curl_ctx->res_len > 0
         && is_json(curl_ctx->content_type)
         && get_json_strings(curl_ctx->res_body, curl_ctx->res_len, ctx->log,
                             "error", &error, (void *)NULL)
@@ -280,116 +241,28 @@ set_err_http(mh_oauth_ctx *ctx, const struct curl_ctx *curl_ctx)
     free(error);
 }
 
-/* Copy service info so we don't have to free it only sometimes. */
-static void
-copy_svc(struct service_info *to, const struct service_info *from)
-{
-    to->display_name = from->display_name;
-#define copy(_field_) to->_field_ = getcpy(from->_field_)
-    copy(name);
-    copy(scope);
-    copy(client_id);
-    copy(client_secret);
-    copy(auth_endpoint);
-    copy(token_endpoint);
-    copy(redirect_uri);
-#undef copy
-}
-
-/* Return profile component node name for a service parameter. */
-static char *
-node_name_for_svc(const char *base_name, const char *svc)
-{
-    char *result = mh_xmalloc(sizeof "oauth-" - 1
-                              + strlen(svc)
-                              + 1            /* '-' */
-                              + strlen(base_name)
-                              + 1            /* '\0' */);
-    sprintf(result, "oauth-%s-%s", svc, base_name);
-    /* TODO: s/_/-/g ? */
-    return result;
-}
-
-/* Update one service_info field if overridden in profile. */
-static void
-update_svc_field(char **field, const char *base_name, const char *svc)
-{
-    char *name = node_name_for_svc(base_name, svc);
-    const char *value = context_find(name);
-    if (value != NULL) {
-        free(*field);
-        *field = getcpy(value);
-    }
-    free(name);
-}
-
-/* Update all service_info fields that are overridden in profile. */
-static boolean
-update_svc(struct service_info *svc, const char *svc_name, mh_oauth_ctx *ctx)
-{
-#define update(name)                                                    \
-    update_svc_field(&svc->name, #name, svc_name);                       \
-    if (svc->name == NULL) {                                             \
-        set_err_details(ctx, MH_OAUTH_BAD_PROFILE, #name " is missing"); \
-        return FALSE;                                                    \
-    }
-    update(scope);
-    update(client_id);
-    update(client_secret);
-    update(auth_endpoint);
-    update(token_endpoint);
-    update(redirect_uri);
-#undef update
-
-    if (svc->name == NULL) {
-        svc->name = getcpy(svc_name);
-    }
-
-    if (svc->display_name == NULL) {
-        svc->display_name = svc->name;
-    }
-
-    return TRUE;
-}
-
 static char *
-make_user_agent()
+make_user_agent(void)
 {
     const char *curl = curl_version_info(CURLVERSION_NOW)->version;
-    char *s = mh_xmalloc(strlen(user_agent)
-                         + 1
-                         + sizeof "libcurl"
-                         + 1
-                         + strlen(curl)
-                         + 1);
-    sprintf(s, "%s libcurl/%s", user_agent, curl);
-    return s;
+    return concat(user_agent, " libcurl/", curl, NULL);
 }
 
 boolean
 mh_oauth_new(mh_oauth_ctx **result, const char *svc_name)
 {
-    mh_oauth_ctx *ctx = *result = mh_xmalloc(sizeof *ctx);
-    size_t i;
+    mh_oauth_ctx *ctx;
 
+    NEW(ctx);
+    *result = ctx;
     ctx->curl = NULL;
 
     ctx->log = NULL;
     ctx->cred_fn = ctx->sasl_client_res = ctx->err_formatted = NULL;
 
-    ctx->svc.name = ctx->svc.display_name = NULL;
-    ctx->svc.scope = ctx->svc.client_id = NULL;
-    ctx->svc.client_secret = ctx->svc.auth_endpoint = NULL;
-    ctx->svc.token_endpoint = ctx->svc.redirect_uri = NULL;
-
-    for (i = 0; i < sizeof SERVICES / sizeof SERVICES[0]; i++) {
-        if (strcmp(SERVICES[i].name, svc_name) == 0) {
-            copy_svc(&ctx->svc, &SERVICES[i]);
-            break;
-        }
-    }
-
-    if (!update_svc(&ctx->svc, svc_name, ctx)) {
+    if (!mh_oauth_get_service_info(svc_name, &ctx->svc, ctx->err_buf,
+                                  sizeof(ctx->err_buf))) {
+       set_err_details(ctx, MH_OAUTH_BAD_PROFILE, ctx->err_buf);
         return FALSE;
     }
 
@@ -453,7 +326,6 @@ mh_oauth_get_err_code(const mh_oauth_ctx *ctx)
 const char *
 mh_oauth_get_err_string(mh_oauth_ctx *ctx)
 {
-    char *result;
     const char *base;
 
     free(ctx->err_formatted);
@@ -487,6 +359,9 @@ mh_oauth_get_err_string(mh_oauth_ctx *ctx)
     case MH_OAUTH_NO_REFRESH:
         base = "no refresh token";
         break;
+    case MH_OAUTH_CRED_USER_NOT_FOUND:
+        base = "user not found in cred file";
+        break;
     case MH_OAUTH_CRED_FILE:
         base = "error loading cred file";
         break;
@@ -494,12 +369,11 @@ mh_oauth_get_err_string(mh_oauth_ctx *ctx)
         base = "unknown error";
     }
     if (ctx->err_details == NULL) {
-        return ctx->err_formatted = getcpy(base);
+        return ctx->err_formatted = mh_xstrdup(base);
     }
-    /* length of the two strings plus ": " and '\0' */
-    result = mh_xmalloc(strlen(base) + strlen(ctx->err_details) + 3);
-    sprintf(result, "%s: %s", base, ctx->err_details);
-    return ctx->err_formatted = result;
+
+    ctx->err_formatted = concat(base, ": ", ctx->err_details, NULL);
+    return ctx->err_formatted;
 }
 
 const char *
@@ -629,7 +503,7 @@ mh_oauth_authorize(const char *code, mh_oauth_ctx *ctx)
         return NULL;
     }
 
-    result = mh_xmalloc(sizeof *result);
+    NEW(result);
     result->ctx = ctx;
     result->access_token = result->refresh_token = NULL;
 
@@ -686,79 +560,94 @@ mh_oauth_cred_free(mh_oauth_cred *cred)
     free(cred);
 }
 
-const char *
-mh_oauth_cred_fn(mh_oauth_ctx *ctx)
-{
-    char *result, *result_if_allocated;
-    const char *svc = ctx->svc.name;
+/* for loading multi-user cred files */
+struct user_creds {
+    mh_oauth_cred *creds;
 
-    char *component = node_name_for_svc("credential-file", svc);
-    result = context_find(component);
-    free(component);
+    /* number of allocated mh_oauth_cred structs above points to */
+    size_t alloc;
 
-    if (result == NULL) {
-        result = mh_xmalloc(sizeof "oauth-" - 1
-                            + strlen(svc)
-                            + 1 /* '\0' */);
-        sprintf(result, "oauth-%s", svc);
-        result_if_allocated = result;
-    } else {
-        result_if_allocated = NULL;
-    }
+    /* number that are actually filled in and used */
+    size_t len;
+};
 
-    if (result[0] != '/') {
-        const char *tmp = m_maildir(result);
-        free(result_if_allocated);
-        result = getcpy(tmp);
+/* If user has an entry in user_creds, return pointer to it.  Else allocate a
+ * new struct in user_creds and return pointer to that. */
+static mh_oauth_cred *
+find_or_alloc_user_creds(struct user_creds user_creds[], const char *user)
+{
+    mh_oauth_cred *creds = user_creds->creds;
+    size_t i;
+    for (i = 0; i < user_creds->len; i++) {
+        if (strcmp(creds[i].user, user) == 0) {
+            return &creds[i];
+        }
     }
-
-    free(ctx->cred_fn);
-    return ctx->cred_fn = result;
+    if (user_creds->alloc == user_creds->len) {
+        user_creds->alloc *= 2;
+        user_creds->creds = mh_xrealloc(user_creds->creds, user_creds->alloc);
+    }
+    creds = user_creds->creds+user_creds->len;
+    user_creds->len++;
+    creds->user = getcpy(user);
+    creds->access_token = creds->refresh_token = NULL;
+    creds->expires_at = 0;
+    return creds;
 }
 
-boolean
-mh_oauth_cred_save(FILE *fp, mh_oauth_cred *cred)
+static void
+free_user_creds(struct user_creds *user_creds)
 {
-    int fd = fileno(fp);
-    if (fchmod(fd, S_IRUSR | S_IWUSR) < 0) goto err;
-    if (ftruncate(fd, 0) < 0) goto err;
-    if (cred->access_token != NULL) {
-        if (fprintf(fp, "access: %s\n", cred->access_token) < 0) goto err;
-    }
-    if (cred->refresh_token != NULL) {
-        if (fprintf(fp, "refresh: %s\n", cred->refresh_token) < 0) goto err;
-    }
-    if (cred->expires_at > 0) {
-        if (fprintf(fp, "expire: %ld\n", (long)cred->expires_at) < 0) goto err;
+    mh_oauth_cred *cred;
+    size_t i;
+    cred = user_creds->creds;
+    for (i = 0; i < user_creds->len; i++) {
+        free(cred[i].user);
+        free(cred[i].access_token);
+        free(cred[i].refresh_token);
     }
-    return TRUE;
-
-  err:
-    set_err(cred->ctx, MH_OAUTH_CRED_FILE);
-    return FALSE;
+    free(user_creds->creds);
+    free(user_creds);
 }
 
 static boolean
-parse_cred(char **access, char **refresh, char **expire, FILE *fp,
-           mh_oauth_ctx *ctx)
+load_creds(struct user_creds **result, FILE *fp, mh_oauth_ctx *ctx)
 {
-    boolean result = FALSE;
+    boolean success = FALSE;
     char name[NAMESZ], value_buf[BUFSIZ];
     int state;
-    m_getfld_state_t getfld_ctx = 0;
+    m_getfld_state_t getfld_ctx;
+
+    struct user_creds *user_creds;
+    NEW(user_creds);
+    user_creds->alloc = 4;
+    user_creds->len = 0;
+    user_creds->creds = mh_xmalloc(user_creds->alloc * sizeof *user_creds->creds);
 
+    getfld_ctx = m_getfld_state_init(fp);
     for (;;) {
        int size = sizeof value_buf;
-       switch (state = m_getfld(&getfld_ctx, name, value_buf, &size, fp)) {
+       switch (state = m_getfld2(&getfld_ctx, name, value_buf, &size)) {
         case FLD:
         case FLDPLUS: {
-            char **save;
-            if (strcmp(name, "access") == 0) {
-                save = access;
-            } else if (strcmp(name, "refresh") == 0) {
-                save = refresh;
-            } else if (strcmp(name, "expire") == 0) {
-                save = expire;
+            char **save, *expire;
+            time_t *expires_at = NULL;
+            if (has_prefix(name, "access-")) {
+                const char *user = name + 7;
+                mh_oauth_cred *creds = find_or_alloc_user_creds(user_creds,
+                                                                user);
+                save = &creds->access_token;
+            } else if (has_prefix(name, "refresh-")) {
+                const char *user = name + 8;
+                mh_oauth_cred *creds = find_or_alloc_user_creds(user_creds,
+                                                                user);
+                save = &creds->refresh_token;
+            } else if (has_prefix(name, "expire-")) {
+                const char *user = name + 7;
+                mh_oauth_cred *creds = find_or_alloc_user_creds(user_creds,
+                                                                user);
+                expires_at = &creds->expires_at;
+                save = &expire;
             } else {
                 set_err_details(ctx, MH_OAUTH_CRED_FILE, "unexpected field");
                 break;
@@ -770,22 +659,33 @@ parse_cred(char **access, char **refresh, char **expire, FILE *fp,
                 char *tmp = getcpy(value_buf);
                 while (state == FLDPLUS) {
                     size = sizeof value_buf;
-                    state = m_getfld(&getfld_ctx, name, value_buf, &size, fp);
+                    state = m_getfld2(&getfld_ctx, name, value_buf, &size);
                     tmp = add(value_buf, tmp);
                 }
                 *save = trimcpy(tmp);
                 free(tmp);
             }
+            if (expires_at != NULL) {
+                errno = 0;
+                *expires_at = strtol(expire, NULL, 10);
+                free(expire);
+                if (errno != 0) {
+                    set_err_details(ctx, MH_OAUTH_CRED_FILE,
+                                    "invalid expiration time");
+                    break;
+                }
+                expires_at = NULL;
+            }
             continue;
         }
 
         case BODY:
         case FILEEOF:
-            result = TRUE;
+            success = TRUE;
             break;
 
         default:
-            /* Not adding details for LENERR/FMTERR because m_getfld already
+            /* Not adding details for LENERR/FMTERR because m_getfld2 already
              * wrote advise message to stderr. */
             set_err(ctx, MH_OAUTH_CRED_FILE);
             break;
@@ -793,41 +693,117 @@ parse_cred(char **access, char **refresh, char **expire, FILE *fp,
        break;
     }
     m_getfld_state_destroy(&getfld_ctx);
-    return result;
+
+    if (success) {
+        *result = user_creds;
+    } else {
+        free_user_creds(user_creds);
+    }
+
+    return success;
+}
+
+static boolean
+save_user(FILE *fp, const char *user, const char *access, const char *refresh,
+          long expires_at)
+{
+    if (access != NULL) {
+        if (fprintf(fp, "access-%s: %s\n", user, access) < 0) return FALSE;
+    }
+    if (refresh != NULL) {
+        if (fprintf(fp, "refresh-%s: %s\n", user, refresh) < 0) return FALSE;
+    }
+    if (expires_at > 0) {
+        if (fprintf(fp, "expire-%s: %ld\n", user, (long)expires_at) < 0) {
+            return FALSE;
+        }
+    }
+    return TRUE;
+}
+
+boolean
+mh_oauth_cred_save(FILE *fp, mh_oauth_cred *cred, const char *user)
+{
+    struct user_creds *user_creds;
+    int fd = fileno(fp);
+    size_t i;
+
+    /* Load existing creds if any. */
+    if (!load_creds(&user_creds, fp, cred->ctx)) {
+        return FALSE;
+    }
+
+    if (fchmod(fd, S_IRUSR | S_IWUSR) < 0) goto err;
+    if (ftruncate(fd, 0) < 0) goto err;
+    if (fseek(fp, 0, SEEK_SET) < 0) goto err;
+
+    /* Write all creds except for this user. */
+    for (i = 0; i < user_creds->len; i++) {
+        mh_oauth_cred *c = &user_creds->creds[i];
+        if (strcmp(c->user, user) == 0) continue;
+        if (!save_user(fp, c->user, c->access_token, c->refresh_token,
+                       c->expires_at)) {
+            goto err;
+        }
+    }
+
+    /* Write updated creds for this user. */
+    if (!save_user(fp, user, cred->access_token, cred->refresh_token,
+                   cred->expires_at)) {
+        goto err;
+    }
+
+    free_user_creds(user_creds);
+
+    return TRUE;
+
+  err:
+    free_user_creds(user_creds);
+    set_err(cred->ctx, MH_OAUTH_CRED_FILE);
+    return FALSE;
 }
 
 mh_oauth_cred *
-mh_oauth_cred_load(FILE *fp, mh_oauth_ctx *ctx)
+mh_oauth_cred_load(FILE *fp, mh_oauth_ctx *ctx, const char *user)
 {
-    mh_oauth_cred *result;
-    time_t expires_at = 0;
-    char *access, *refresh, *expire;
-
-    access = refresh = expire = NULL;
-    if (!parse_cred(&access, &refresh, &expire, fp, ctx)) {
-        free(access);
-        free(refresh);
-        free(expire);
+    mh_oauth_cred *creds, *result = NULL;
+    struct user_creds *user_creds;
+    size_t i;
+
+    if (!load_creds(&user_creds, fp, ctx)) {
         return NULL;
     }
 
-    if (expire != NULL) {
-        errno = 0;
-        expires_at = strtol(expire, NULL, 10);
-        free(expire);
-        if (errno != 0) {
-            set_err_details(ctx, MH_OAUTH_CRED_FILE, "invalid expiration time");
-            free(access);
-            free(refresh);
-            return NULL;
+    /* Search user_creds for this user.  If we don't find it, return NULL.
+     * If we do, free fields of all structs except this one, moving this one to
+     * the first struct if necessary.  When we return it, it just looks like one
+     * struct to the caller, and the whole array is freed later. */
+    creds = user_creds->creds;
+    for (i = 0; i < user_creds->len; i++) {
+        if (strcmp(creds[i].user, user) == 0) {
+            result = creds;
+            if (i > 0) {
+                result->access_token = creds[i].access_token;
+                result->refresh_token = creds[i].refresh_token;
+                result->expires_at = creds[i].expires_at;
+            }
+        } else {
+            free(creds[i].access_token);
+            free(creds[i].refresh_token);
         }
+        free(creds[i].user);
+    }
+
+    /* No longer need user_creds.  result just uses its creds member. */
+    free(user_creds);
+
+    if (result == NULL) {
+        set_err_details(ctx, MH_OAUTH_CRED_USER_NOT_FOUND, user);
+        return NULL;
     }
 
-    result = mh_xmalloc(sizeof *result);
     result->ctx = ctx;
-    result->access_token = access;
-    result->refresh_token = refresh;
-    result->expires_at = expires_at;
+    result->user = NULL;
 
     return result;
 }
@@ -836,17 +812,13 @@ const char *
 mh_oauth_sasl_client_response(size_t *res_len,
                               const char *user, const mh_oauth_cred *cred)
 {
-    size_t len = sizeof "user=" - 1
-        + strlen(user)
-        + sizeof "\1auth=Bearer " - 1
-        + strlen(cred->access_token)
-        + sizeof "\1\1" - 1;
-    free(cred->ctx->sasl_client_res);
-    cred->ctx->sasl_client_res = mh_xmalloc(len + 1);
-    *res_len = len;
-    sprintf(cred->ctx->sasl_client_res, "user=%s\1auth=Bearer %s\1\1",
-            user, cred->access_token);
-    return cred->ctx->sasl_client_res;
+    char **p;
+
+    p = &cred->ctx->sasl_client_res;
+    free(*p);
+    *p = concat("user=", user, "\1auth=Bearer ", cred->access_token, "\1\1", NULL);
+    *res_len = strlen(*p);
+    return *p;
 }
 
 /*******************************************************************************
@@ -872,7 +844,10 @@ make_query_url(char *s, size_t size, CURL *curl, const char *base_url, ...)
         len = 0;
         prefix = "";
     } else {
-        len = sprintf(s, "%s", base_url);
+        len = strlen(base_url);
+        if (len > size - 1) /* Less one for NUL. */
+            return FALSE;
+        strcpy(s, base_url);
         prefix = "?";
     }
 
@@ -906,7 +881,7 @@ make_query_url(char *s, size_t size, CURL *curl, const char *base_url, ...)
 }
 
 static int
-debug_callback(const CURL *handle, curl_infotype type, const char *data,
+debug_callback(CURL *handle, curl_infotype type, char *data,
                size_t size, void *userptr)
 {
     FILE *fp = userptr;
@@ -926,7 +901,7 @@ debug_callback(const CURL *handle, curl_infotype type, const char *data,
     }
     fwrite(data, 1, size, fp);
     if (data[size - 1] != '\n') {
-        fputs("\n", fp);
+        putc('\n', fp);
     }
     fflush(fp);
     return 0;
@@ -978,6 +953,14 @@ post(struct curl_ctx *ctx, const char *url, const char *req_body)
     curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
     curl_easy_setopt(curl, CURLOPT_WRITEDATA, ctx);
 
+    if (has_prefix(url, "http://127.0.0.1:")) {
+        /* Hack:  on Cygwin, curl doesn't fail to connect with ECONNREFUSED.
+           Instead, it waits to timeout.  So set a really short timeout, but
+           just on localhost (for convenience of the user, and the test
+           suite). */
+        curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 2L);
+    }
+
     status = curl_easy_perform(curl);
     /* first check for error from callback */
     if (ctx->too_big) {
@@ -1036,7 +1019,7 @@ parse_json(jsmntok_t **tokens, size_t *tokens_len,
            of the response body. */
         *tokens = mh_xrealloc(*tokens, *tokens_len * sizeof **tokens);
     }
-    if (r == 0) {
+    if (r <= 0) {
         return FALSE;
     }
 
@@ -1152,5 +1135,4 @@ get_json_strings(const char *input, size_t input_len, FILE *log, ...)
     free(tokens);
     return result;
 }
-
 #endif