Q:

Connecting to Google via OAuth 2, "invalid_request" when requesting an access token

There are several questions along these lines already on SE, but I've read everything I can find that seems relevant, and I'm still not quite there.
I got an authentication code, so now I need to exchange it for an access token and a refresh token.  However, Google returns the wonderfully non-specific error "invalid_request".  Here's my code:
private const string  BaseAccessTokenUrl = "https://accounts.google.com/o/oauth2/token";
private const string  ContentType        = "application/x-www-form-urlencoded";

public static string  GetRefreshToken(string clientId, string clientSecret, string authCode)
    {
    Dictionary<string, string>  parameters = new Dictionary<string, string>
        {
        { "code",          authCode },
        { "client_id",     clientId },
        { "client_secret", clientSecret },
        { "redirect_uri",  "http://localhost" },
        { "grant_type",    "authorization_code" }
        };
    string  rawJson = WebUtilities.Post(BaseAccessTokenUrl, parameters, ContentType);
    return rawJson;  // TODO: Parse out the actual refresh token
    }

My Post() method URL-encodes the parameters keys and values and concatenates them:
public static string  Post(string uri, Dictionary<string, string> properties, string contentType = "application/x-www-form-urlencoded")
    {
    string  content = String.Join("&", from kvp in properties select UrlEncode(kvp.Key) + "=" + UrlEncode(kvp.Value) );
    return Post(uri, content);
    }

The two-parameter Post() method just handles converting the content to bytes, adding content-length, etc., then returns the contents of the response even if it came as a WebException.  I can include it if it's of any interest.
The authorization code looks right, it's similar to others I've seen: 62 characters, and it starts with "4/".  The client ID, secret, and redirect URL I've carefully copied from the Google API Console.  The app is registered as an "Other" app, and I'm connecting from a Windows machine.
Per this and this post, I've tried NOT URL-encoding, with no change.  The OAuth Playground suggests that URL-encoding is correct.
Per this post and this one, the properties are concatenated on a single line.
Per this post, I've tried approval_prompt=force in the authorization request, but the new auth code did not work any better.  Do auth codes expire?  I'm using new codes within a few seconds, usually.
Per the Google docs and this post, I'm using content-type "application/x-www-form-encoded".
My authorization request is for scope "https://www.googleapis.com/auth/analytics.readonly".
Per this post, there's no leading question mark in the parameters.
There is a Google .NET OAuth library, but I was not able to get it working easily, and ~50,000 lines of code is more than I'd like to study if I have a choice.  I prefer to write something clean from the ground up than to blindly copy over a bunch of libraries, cargo cult-style.

A:

Found it.  The redirect_uri used to request tokens needs to match what was used when getting the authorization code.  Here's my working code to get an auth code:
private const string  BaseAuthorizationUrl = "https://accounts.google.com/o/oauth2/auth";
public string  GetAuthorizationUrl(string clientId, IEnumerable<string> scopes)
    {
    var  parameters = new Dictionary<string, string>
        {
        { "response_type",   "code" },
        { "client_id",       clientId },
        { "redirect_uri",    RedirectUrl },
        { "scope",           String.Join(" ", scopes) },
        { "approval_prompt", "auto" }
        };
    return WebUtilities.BuildUrl(BaseAuthorizationUrl, parameters);
    }

...here's my code to get an access token and a refresh token:
private const string  BaseAccessTokenUrl = "https://accounts.google.com/o/oauth2/token";
public void  GetTokens(string clientId, string clientSecret, string authorizationCode, out string accessToken, out string refreshToken)
    {
    var  parameters = new Dictionary<string, string>
        {
        { "code",          authorizationCode },
        { "redirect_uri",  RedirectUrl },  // Must match that used when authorizing an app
        { "client_id",     clientId },
        { "scope",         String.Empty },
        { "client_secret", clientSecret },
        { "grant_type",    "authorization_code" }
        };
    string   rawJson    = WebUtilities.Post(BaseAccessTokenUrl, parameters, "application/x-www-form-urlencoded");
    dynamic  parsedJson = JsonUtilities.DeserializeObject(rawJson);
    accessToken  = parsedJson.access_token;
    refreshToken = parsedJson.refresh_token;
    }

...here's the code to get a fresh access token:
public string  GetAccessToken(string clientId, string clientSecret, string refreshToken)
    {
    var  parameters = new Dictionary<string, string>
        {
        { "client_id",     clientId },
        { "client_secret", clientSecret },
        { "refresh_token", refreshToken },
        { "grant_type",    "refresh_token" }
        };
    string   rawJson    = WebUtilities.Post(BaseAccessTokenUrl, parameters, "application/x-www-form-urlencoded");
    dynamic  parsedJson = JsonUtilities.DeserializeObject(rawJson);
    return parsedJson.access_token;
    }

