Use google credentials to login into UWP C# app
Asked Answered
W

3

6

I'm trying to make a login for a UWP app that I'm developing for a client that has a @<theircompay>.com email that uses G Suite. It doesn't have to access any user data, they just want it as an authentication so that only people that have a company email can access the app.

It would be great if they could login from within the app without having to use a web browser, and even better if it could remember them so they wouldn't have to login every single time.

I've been looking at OAuth 2.0 and several other solutions google has but can't really understand which one to use and much less how.

I looked into this answer but it doesn't seem like a good idea to ship your certificate file with your app.

So basically if this can be done, what (if any) certificates or credentials do I need to get from Google, and how would I handle them and the login through my C# code?

Edit

The app is 100% client side, no server backend

Woodham answered 13/5, 2017 at 0:25 Comment(1)
As noted by Romasz, we don't support UWP in the Google API client libraries at the moment. When we do, I strongly expect that authentication via a web browser will be the preferred option, but you should be able to cache those credentials.Aw
S
3

Taking a look at Google's GitHub it seems that .Net API is still not ready for UWP (however if you traverse the issues you will find that they are working on it, so it's probably a matter of time when official version is ready and this answer would be obsolete).

As I think getting simple accessToken (optionaly refresing it) to basic profile info should be sufficient for this case. Basing on available samples from Google I've build a small project (source at GitHub), that can help you.

So first of all you have to define your app at Google's developer console and obtain ClientID and ClientSecret. Once you have this you can get to coding. To obtain accessToken I will use a WebAuthenticationBroker:

string authString = "https://accounts.google.com/o/oauth2/auth?client_id=" + ClientID;
authString += "&scope=profile";
authString += $"&redirect_uri={RedirectURI}";
authString += $"&state={state}";
authString += $"&code_challenge={code_challenge}";
authString += $"&code_challenge_method={code_challenge_method}";
authString += "&response_type=code";

var receivedData = await WebAuthenticationBroker.AuthenticateAsync(WebAuthenticationOptions.UseTitle, new Uri(authString), new Uri(ApprovalEndpoint));

switch (receivedData.ResponseStatus)
{
    case WebAuthenticationStatus.Success:
        await GetAccessToken(receivedData.ResponseData.Substring(receivedData.ResponseData.IndexOf(' ') + 1), state, code_verifier);
        return true;
    case WebAuthenticationStatus.ErrorHttp:
        Debug.WriteLine($"HTTP error: {receivedData.ResponseErrorDetail}");
        return false;

    case WebAuthenticationStatus.UserCancel:
    default:
        return false;
}

If everything goes all right and user puts correct credentials, you will have to ask Google for tokens (I assume that you only want the user to put credentials once). For this purpose you have the method GetAccessToken:

// Parses URI params into a dictionary - ref: https://mcmap.net/q/121346/-easiest-way-to-parse-quot-querystring-quot-formatted-data 
Dictionary<string, string> queryStringParams = data.Split('&').ToDictionary(c => c.Split('=')[0], c => Uri.UnescapeDataString(c.Split('=')[1]));

StringContent content = new StringContent($"code={queryStringParams["code"]}&client_secret={ClientSecret}&redirect_uri={Uri.EscapeDataString(RedirectURI)}&client_id={ClientID}&code_verifier={codeVerifier}&grant_type=authorization_code",
                                          Encoding.UTF8, "application/x-www-form-urlencoded");

HttpResponseMessage response = await httpClient.PostAsync(TokenEndpoint, content);
string responseString = await response.Content.ReadAsStringAsync();

if (!response.IsSuccessStatusCode)
{
    Debug.WriteLine("Authorization code exchange failed.");
    return;
}

JsonObject tokens = JsonObject.Parse(responseString);
accessToken = tokens.GetNamedString("access_token");

foreach (var item in vault.RetrieveAll().Where((x) => x.Resource == TokenTypes.AccessToken.ToString() || x.Resource == TokenTypes.RefreshToken.ToString())) vault.Remove(item);

vault.Add(new PasswordCredential(TokenTypes.AccessToken.ToString(), "MyApp", accessToken));
vault.Add(new PasswordCredential(TokenTypes.RefreshToken.ToString(), "MyApp", tokens.GetNamedString("refresh_token")));
TokenLastAccess = DateTimeOffset.UtcNow;

Once you have the tokens (I'm saving them in PasswordVault for safety), you can later then use them to authenticate without asking the user for his credentials. Note that accessToken has limited lifetime, therefore you use refreshToken to obtain a new one:

if (DateTimeOffset.UtcNow < TokenLastAccess.AddSeconds(3600))
{
    // is authorized - no need to Sign In
    return true;
}
else
{
    string token = GetTokenFromVault(TokenTypes.RefreshToken);
    if (!string.IsNullOrWhiteSpace(token))
    {
        StringContent content = new StringContent($"client_secret={ClientSecret}&refresh_token={token}&client_id={ClientID}&grant_type=refresh_token",
                                                  Encoding.UTF8, "application/x-www-form-urlencoded");

        HttpResponseMessage response = await httpClient.PostAsync(TokenEndpoint, content);
        string responseString = await response.Content.ReadAsStringAsync();

        if (response.IsSuccessStatusCode)
        {
            JsonObject tokens = JsonObject.Parse(responseString);

            accessToken = tokens.GetNamedString("access_token");

            foreach (var item in vault.RetrieveAll().Where((x) => x.Resource == TokenTypes.AccessToken.ToString())) vault.Remove(item);

            vault.Add(new PasswordCredential(TokenTypes.AccessToken.ToString(), "MyApp", accessToken));
            TokenLastAccess = DateTimeOffset.UtcNow;
            return true;
        }
    }
}

The code above is only a sample (with some shortcuts) and as mentioned above - a working version with some more error handling you will find at my GitHub. Please also note, that I haven't spend much time on this and it will surely need some more work to handle all the cases and possible problems. Though hopefully will help you to start.

Snippet answered 13/5, 2017 at 9:26 Comment(7)
This is great, exactly what I needed. Thank you for the full code on GitHubWoodham
should we really hardcode client secret in the source code?Lintel
@batmaci Somehow you need the client secret, it's role seems to be reduced, nevertheless it's still required.Snippet
i tried your code but somehow it doest work. it keeps returning ""We can't connect to the service you need right now .Check your network connection and try this again later."" error. do you have any idea? i double checked my clientid and secret seems to be correctLintel
@batmaci Not sure. Check the developer console at google.Snippet
I think that it has something to do with redirecturl. is it really the correct usage? urn:ietf:wg:oauth:2.0:oob. dont we need to register this url in the google console? but it doesnt accept as it requires http or https.Lintel
Google discontinued support of OOB method which this answer is based on (you can say it because the URL is urn:ietf:wg:oauth:2.0:oob). Not sure how now we can deal with it??!Autointoxication
L
1

Answer from Roamsz is great but didnt work for me because I found some conflicts or at least with the latest build 17134 as target, it doesn't work. Here are the problem, in his Github sample, he is using returnurl as urn:ietf:wg:oauth:2.0:oob . this is the type of url, you can't use with web application type when you create new "Create OAuth client ID" in the google or firebase console. you must use "Ios" as shown below. because web application requires http or https urls as return url.

from google doc

enter image description here

enter image description here

According to his sample he is using Client secret to obtain access token, this is not possible if you create Ios as type. because Android and Ios arent using client secret. It is perfectly described over here

client_secret The client secret obtained from the API Console. This value is not needed for clients registered as Android, iOS, or Chrome applications.

So you must use type as Ios, No Client Secret needed and return url is urn:ietf:wg:oauth:2.0:oob or urn:ietf:wg:oauth:2.0:oob:auto difference is that auto closes browser and returns back to the app. other one, code needs to be copied manually. I prefer to use urn:ietf:wg:oauth:2.0:oob:auto

Regarding code: please follow his github code. Just remove the Client Secret from the Access Token Request.

EDIT: it looks like I was right that even offical sample is not working after UWP version 15063, somebody created an issue on their github

https://github.com/Microsoft/Windows-universal-samples/issues/642

Lintel answered 11/6, 2018 at 0:15 Comment(0)
A
1

First you need to obtain Client ID, Secret & Redirect URI:

  1. Go to the Credentials page.
  2. Click Create credentials > OAuth client ID.
  3. Select UWP application type and provide Store ID of your app (find it in Microsoft Partner Center under App Identity section.
  4. Then you get you Client ID and Client Secret.
  5. Most important part - create the right Redirect URI. For example, if your Client ID is 123456789-abcdefgh.apps.googleusercontent.com then your redirect URI will be com.googleusercontent.apps.123456789-abcdefgh:/oauth2redirect. So you need to reverse Client ID and add an optional path (you can use whatever path you want instead of /oath2redirect).

Now using the class posted below you can get Access Token in the following way:

internal class GoogleLoginHelper
{
    private const string CLIENT_ID = "PUT YOUR CLIENT ID HERE";
    private const string CLIENT_SECRET = "PUT YOUR SECRET HERE"; 
    private const string TOKEN_ENDPOINT = "https://www.googleapis.com/oauth2/v4/token";
    private const string REDIRECT_URI = "PUT YOUR REDIRECT URI HERE"; // reverce clientID + optional path

    private HttpClient _httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = true });

    public async Task<string> GetAccessToken()
    {
        // Generates state and PKCE values.
        string state = RandomDataBase64url(32);
        string code_verifier = RandomDataBase64url(32);
        string code_challenge = Base64urlencodeNoPadding(Sha256(code_verifier));
        const string code_challenge_method = "S256";

        string authString = "https://accounts.google.com/o/oauth2/auth?client_id=" + CLIENT_ID;
        authString += "&scope=profile%20email";
        authString += $"&redirect_uri={Uri.EscapeDataString(REDIRECT_URI)}";
        authString += $"&state={state}";
        authString += $"&code_challenge={code_challenge}";
        authString += $"&code_challenge_method={code_challenge_method}";
        authString += "&response_type=code";


        var receivedData = await WebAuthenticationBroker.AuthenticateAsync(
            WebAuthenticationOptions.None,
            new Uri(authString),
            new Uri(REDIRECT_URI));


        switch (receivedData.ResponseStatus)
        {
            case WebAuthenticationStatus.Success:
                return await GetAccessToken(receivedData.ResponseData, state, code_verifier);
            case WebAuthenticationStatus.ErrorHttp:
                var err = $"HTTP error: {receivedData.ResponseErrorDetail}";
                Debug.WriteLine(err);
                return null;
            case WebAuthenticationStatus.UserCancel:
            default:
                return null; // Login cancelled
        }
    }


    // Private


    private async Task<string> GetAccessToken(string data, string expectedState, string codeVerifier)
    {
        // Parses URI params into a dictionary - ref: https://mcmap.net/q/121346/-easiest-way-to-parse-quot-querystring-quot-formatted-data 
        var p = data.IndexOf("?");
        if (p == -1) { return null; }
        data = data.Substring(p + 1);
        Dictionary<string, string> queryStringParams = data.Split('&').ToDictionary(c => c.Split('=')[0], c => Uri.UnescapeDataString(c.Split('=')[1]));

        if (queryStringParams.ContainsKey("error"))
        {
            Debug.WriteLine($"OAuth error: {queryStringParams["error"]}.");
            return null;
        }

        if (!queryStringParams.ContainsKey("code") || !queryStringParams.ContainsKey("state"))
        {
            Debug.WriteLine($"Wrong response {data}");
            return null;
        }

        if (queryStringParams["state"] != expectedState)
        {
            Debug.WriteLine($"Invalid state {queryStringParams["state"]}");
            return null;
        }

        var content = new StringContent(
            $"code={queryStringParams["code"]}&client_secret={CLIENT_SECRET}&redirect_uri={Uri.EscapeDataString(REDIRECT_URI)}&client_id={CLIENT_ID}&code_verifier={codeVerifier}&grant_type=authorization_code",
            Encoding.UTF8, 
            "application/x-www-form-urlencoded");

        HttpResponseMessage response = await _httpClient.PostAsync(TOKEN_ENDPOINT, content);
        string responseString = await response.Content.ReadAsStringAsync();

        if (!response.IsSuccessStatusCode)
        {
            Debug.WriteLine("Authorization code exchange failed.");
            return null;
        }

        JsonObject tokens = JsonObject.Parse(responseString);
        var accessToken = tokens.GetNamedString("access_token");
        return accessToken;
    }



    // Helper methods

    /// <summary>
    /// Base64url no-padding encodes the given input buffer.
    /// </summary>
    /// <param name="buffer"></param>
    /// <returns></returns>
    public static string Base64urlencodeNoPadding(IBuffer buffer)
    {
        string base64 = CryptographicBuffer.EncodeToBase64String(buffer);

        // Converts base64 to base64url.
        base64 = base64.Replace("+", "-");
        base64 = base64.Replace("/", "_");
        // Strips padding.
        base64 = base64.Replace("=", "");

        return base64;
    }

    /// <summary>
    /// Returns URI-safe data with a given input length.
    /// </summary>
    /// <param name="length">Input length (nb. output will be longer)</param>
    /// <returns></returns>
    public static string RandomDataBase64url(uint length)
    {
        IBuffer buffer = CryptographicBuffer.GenerateRandom(length);
        return Base64urlencodeNoPadding(buffer);
    }

    /// <summary>
    /// Returns the SHA256 hash of the input string.
    /// </summary>
    /// <param name="inputString"></param>
    /// <returns></returns>
    public static IBuffer Sha256(string inputString)
    {
        HashAlgorithmProvider sha = HashAlgorithmProvider.OpenAlgorithm(HashAlgorithmNames.Sha256);
        IBuffer buff = CryptographicBuffer.ConvertStringToBinary(inputString, BinaryStringEncoding.Utf8);
        return sha.HashData(buff);
    }

}

NOTE 1:

My code returns you an Access Token. Reference to Romasz answer / source code if you need to retrieve user info, Refresh Token or do more things (but keep in mind that you will need Access Token anyway).

NOTE 2:

My answer based on Romasz, with some corrections for 2023. The main difference is that you now can't use OOB method like in the orginal answer. This means you can't use callback URL urn:ietf:wg:oauth:2.0:oob because Google discontinued it. Other minor fixes were made to make it works with Custom URL Scheme.

OLD ANSWER - DO NOT USE (won't work)

The answer below doesn't work anymore - see more details here.

I'm using pretty straightforward code with Google.Apis.Oauth2.v2 Nuget package. Note, that I'm using v.1.25.0.859 of that package. I tried to update to the lastest version (1.37.0.1404), but this surprisingly doesn't work with UWP. At the same time v. 1.25.0.859 works just fine.

So, unless there's a better option, I would recommend to use a bit old, but working version of Nuget package.

This is my code:

            credential = await GoogleWebAuthorizationBroker.AuthorizeAsync(
                new Uri("ms-appx:///Assets/User/Auth/google_client_secrets.json"),
                new[] { "profile", "email" },
                "me",
                CancellationToken.None);

            await GoogleWebAuthorizationBroker.ReauthorizeAsync(credential, CancellationToken.None);

Then you can retrieve access token from: credential.Token.AccessToken.

Autointoxication answered 14/1, 2019 at 18:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.