10

I have found many different solutions with OAuth and either with some libraries or with pure requests (https://github.com/googlesamples/oauth-apps-for-windows).

However, none of the solutions looks like the one that I really need. Currently, my application uses its own database for users to log in using WCF service request (with username and password). However, all users have their domain e-mail created with Google accounts, so I want to add another button "Sign In with Google", which will just make sure that user can also login with his Google e-mail-password pair. I don't need a returned token for the further use etc.

What is the simplest way to achieve this functionality in WPF/C# desktop application?

Mike Strobel
  • 25,075
  • 57
  • 69
Gab
  • 471
  • 3
  • 10
  • 25
  • 1
    Here's a random hint from your linked example: *"If you have a question related to these samples, or Google OAuth in general, please ask on Stack Overflow with the `google-oauth` tag"*. So basically, you chose the wrong tags to get google support on the matter (however, I have no idea whether the right tag would attract some quality support). – grek40 Jan 25 '18 at 13:12
  • I've used the methods described here: https://developers.google.com/api-client-library/dotnet/guide/aaa_oauth . See the "installaed applications" section for information on how you would do it from a desktop application. – Bradley Uffner Jan 25 '18 at 16:01
  • You need to get oauth authorization code (via embedded\external browser), then post this code instead of username\password pair to your server. Then on server you exchange code for id token, which contains information about user (including email). Now you can authenticate user with this email and establish session as usual, without explicit username or password. – Evk Jan 30 '18 at 04:19

2 Answers2

18

Here is a self-sufficient, 3rd party-free, WPF sample that does Google auth (it could be easily converted to winforms as well).

If you run it you won't be logged and the app will show you a button. If you click that button, an embedded webbrowser control will run though the Google auth. Once you are authenticated, the app will just display your name as returned by Google.

Note it's based on official Google's sample here: https://github.com/googlesamples/oauth-apps-for-windows but it uses an embedded browser instead of spawning an external one (and few other differences).

XAML code:

  <Window x:Class="GoogleAuth.MainWindow"
          xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
          xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
          Title="MainWindow" Height="750" Width="525">
      <Window.Resources>
          <BooleanToVisibilityConverter x:Key="btv" />
      </Window.Resources>
      <Grid>
          <DockPanel Visibility="{Binding State.IsSigned, Converter={StaticResource btv}}">
              <Label>You are signed as:</Label>
              <Label Content="{Binding State.Token.Name}" />
          </DockPanel>
          <Grid Visibility="{Binding State.IsNotSigned, Converter={StaticResource btv}}">
              <Grid.RowDefinitions>
                  <RowDefinition Height="23" />
                  <RowDefinition Height="*" />
              </Grid.RowDefinitions>
              <Button Click="Button_Click">Click to Sign In</Button>
              <WebBrowser Grid.Row="1" x:Name="Wb" Height="Auto" />
          </Grid>
      </Grid>
  </Window>

C# code:

  using System;
  using System.ComponentModel;
  using System.IO;
  using System.Net;
  using System.Net.Sockets;
  using System.Runtime.Serialization;
  using System.Runtime.Serialization.Json;
  using System.Security.Cryptography;
  using System.Text;
  using System.Threading;
  using System.Windows;
  using System.Windows.Threading;

  namespace GoogleAuth
  {
      public partial class MainWindow : Window
      {
          public MainWindow()
          {
              InitializeComponent();
              State = new OAuthState();
              DataContext = this;
          }

          public OAuthState State { get; }

          private void Button_Click(object sender, RoutedEventArgs e)
          {
              var thread = new Thread(HandleRedirect);
              thread.Start();
          }

          private async void HandleRedirect()
          {
              State.Token = null;

              // for example, let's pretend I want also want to have access to WebAlbums
              var scopes = new string[] { "https://picasaweb.google.com/data/" };

              var request = OAuthRequest.BuildLoopbackRequest(scopes);
              var listener = new HttpListener();
              listener.Prefixes.Add(request.RedirectUri);
              listener.Start();

              // note: add a reference to System.Windows.Presentation and a 'using System.Windows.Threading' for this to compile
              await Dispatcher.BeginInvoke(() =>
              {
                  Wb.Navigate(request.AuthorizationRequestUri);
              });

              // here, we'll wait for redirection from our hosted webbrowser
              var context = await listener.GetContextAsync();

              // browser has navigated to our small http servern answer anything here
              string html = string.Format("<html><body></body></html>");
              var buffer = Encoding.UTF8.GetBytes(html);
              context.Response.ContentLength64 = buffer.Length;
              var stream = context.Response.OutputStream;
              var responseTask = stream.WriteAsync(buffer, 0, buffer.Length).ContinueWith((task) =>
              {
                  stream.Close();
                  listener.Stop();
              });

              string error = context.Request.QueryString["error"];
              if (error != null)
                  return;

              string state = context.Request.QueryString["state"];
              if (state != request.State)
                  return;

              string code = context.Request.QueryString["code"];
              State.Token = request.ExchangeCodeForAccessToken(code);
          }
      }

      // state model
      public class OAuthState : INotifyPropertyChanged
      {
          public event PropertyChangedEventHandler PropertyChanged;

          private OAuthToken _token;
          public OAuthToken Token
          {
              get => _token;
              set
              {
                  if (_token == value)
                      return;

                  _token = value;
                  PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Token)));
                  PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSigned)));
                  PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsNotSigned)));
              }
          }

          public bool IsSigned => Token != null && Token.ExpirationDate > DateTime.Now;
          public bool IsNotSigned => !IsSigned;
      }

      // This is a sample. Fille information (email, etc.) can depend on scopes
      [DataContract]
      public class OAuthToken
      {
          [DataMember(Name = "access_token")]
          public string AccessToken { get; set; }

          [DataMember(Name = "token_type")]
          public string TokenType { get; set; }

          [DataMember(Name = "expires_in")]
          public int ExpiresIn { get; set; }

          [DataMember(Name = "refresh_token")]
          public string RefreshToken { get; set; }

          [DataMember]
          public string Name { get; set; }

          [DataMember]
          public string Email { get; set; }

          [DataMember]
          public string Picture { get; set; }

          [DataMember]
          public string Locale { get; set; }

          [DataMember]
          public string FamilyName { get; set; }

          [DataMember]
          public string GivenName { get; set; }

          [DataMember]
          public string Id { get; set; }

          [DataMember]
          public string Profile { get; set; }

          [DataMember]
          public string[] Scopes { get; set; }

          // not from google's response, but we store this
          public DateTime ExpirationDate { get; set; }
      }

      // largely inspired from
      // https://github.com/googlesamples/oauth-apps-for-windows
      public sealed class OAuthRequest
      {
          // TODO: this is a sample, please change these two, use your own!
          private const string ClientId = "581786658708-elflankerquo1a6vsckabbhn25hclla0.apps.googleusercontent.com";
          private const string ClientSecret = "3f6NggMbPtrmIBpgx-MK2xXK";

          private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
          private const string TokenEndpoint = "https://www.googleapis.com/oauth2/v4/token";
          private const string UserInfoEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo";

          private OAuthRequest()
          {
          }

          public string AuthorizationRequestUri { get; private set; }
          public string State { get; private set; }
          public string RedirectUri { get; private set; }
          public string CodeVerifier { get; private set; }
          public string[] Scopes { get; private set; }

          // https://developers.google.com/identity/protocols/OAuth2InstalledApp
          public static OAuthRequest BuildLoopbackRequest(params string[] scopes)
          {
              var request = new OAuthRequest
              {
                  CodeVerifier = RandomDataBase64Url(32),
                  Scopes = scopes
              };

              string codeChallenge = Base64UrlEncodeNoPadding(Sha256(request.CodeVerifier));
              const string codeChallengeMethod = "S256";

              string scope = BuildScopes(scopes);

              request.RedirectUri = string.Format("http://{0}:{1}/", IPAddress.Loopback, GetRandomUnusedPort());
              request.State = RandomDataBase64Url(32);
              request.AuthorizationRequestUri = string.Format("{0}?response_type=code&scope=openid%20profile{6}&redirect_uri={1}&client_id={2}&state={3}&code_challenge={4}&code_challenge_method={5}",
                  AuthorizationEndpoint,
                  Uri.EscapeDataString(request.RedirectUri),
                  ClientId,
                  request.State,
                  codeChallenge,
                  codeChallengeMethod,
                  scope);

              return request;
          }

          // https://developers.google.com/identity/protocols/OAuth2InstalledApp Step 5: Exchange authorization code for refresh and access tokens
          public OAuthToken ExchangeCodeForAccessToken(string code)
          {
              if (code == null)
                  throw new ArgumentNullException(nameof(code));

              string tokenRequestBody = string.Format("code={0}&redirect_uri={1}&client_id={2}&code_verifier={3}&client_secret={4}&scope=&grant_type=authorization_code",
                  code,
                  Uri.EscapeDataString(RedirectUri),
                  ClientId,
                  CodeVerifier,
                  ClientSecret
                  );

              return TokenRequest(tokenRequestBody, Scopes);
          }

          // this is not used in this sample, but can be used to refresh a token from an old one
          // https://developers.google.com/identity/protocols/OAuth2InstalledApp Refreshing an access token
          public OAuthToken Refresh(OAuthToken oldToken)
          {
              if (oldToken == null)
                  throw new ArgumentNullException(nameof(oldToken));

              string tokenRequestBody = string.Format("refresh_token={0}&client_id={1}&client_secret={2}&grant_type=refresh_token",
                  oldToken.RefreshToken,
                  ClientId,
                  ClientSecret
                  );

              return TokenRequest(tokenRequestBody, oldToken.Scopes);
          }

          private static T Deserialize<T>(string json)
          {
              if (string.IsNullOrWhiteSpace(json))
                  return default(T);

              return Deserialize<T>(Encoding.UTF8.GetBytes(json));
          }

          private static T Deserialize<T>(byte[] json)
          {
              if (json == null || json.Length == 0)
                  return default(T);

              using (var ms = new MemoryStream(json))
              {
                  return Deserialize<T>(ms);
              }
          }

          private static T Deserialize<T>(Stream json)
          {
              if (json == null)
                  return default(T);

              var ser = CreateSerializer(typeof(T));
              return (T)ser.ReadObject(json);
          }

          private static DataContractJsonSerializer CreateSerializer(Type type)
          {
              if (type == null)
                  throw new ArgumentNullException(nameof(type));

              var settings = new DataContractJsonSerializerSettings
              {
                  DateTimeFormat = new DateTimeFormat("yyyy-MM-dd'T'HH:mm:ss.fffK")
              };
              return new DataContractJsonSerializer(type, settings);
          }

          // https://stackoverflow.com/questions/223063/how-can-i-create-an-httplistener-class-on-a-random-port-in-c/
          private static int GetRandomUnusedPort()
          {
              var listener = new TcpListener(IPAddress.Loopback, 0);
              listener.Start();
              var port = ((IPEndPoint)listener.LocalEndpoint).Port;
              listener.Stop();
              return port;
          }

          private static string RandomDataBase64Url(int length)
          {
              using (var rng = new RNGCryptoServiceProvider())
              {
                  var bytes = new byte[length];
                  rng.GetBytes(bytes);
                  return Base64UrlEncodeNoPadding(bytes);
              }
          }

          private static byte[] Sha256(string text)
          {
              using (var sha256 = new SHA256Managed())
              {
                  return sha256.ComputeHash(Encoding.ASCII.GetBytes(text));
              }
          }

          private static string Base64UrlEncodeNoPadding(byte[] buffer)
          {
              string b64 = Convert.ToBase64String(buffer);
              // converts base64 to base64url.
              b64 = b64.Replace('+', '-');
              b64 = b64.Replace('/', '_');
              // strips padding.
              b64 = b64.Replace("=", "");
              return b64;
          }

          private static OAuthToken TokenRequest(string tokenRequestBody, string[] scopes)
          {
              var request = (HttpWebRequest)WebRequest.Create(TokenEndpoint);
              request.Method = "POST";
              request.ContentType = "application/x-www-form-urlencoded";
              byte[] bytes = Encoding.ASCII.GetBytes(tokenRequestBody);
              using (var requestStream = request.GetRequestStream())
              {
                  requestStream.Write(bytes, 0, bytes.Length);
              }

              var response = request.GetResponse();
              using (var responseStream = response.GetResponseStream())
              {
                  var token = Deserialize<OAuthToken>(responseStream);
                  token.ExpirationDate = DateTime.Now + new TimeSpan(0, 0, token.ExpiresIn);
                  var user = GetUserInfo(token.AccessToken);
                  token.Name = user.Name;
                  token.Picture = user.Picture;
                  token.Email = user.Email;
                  token.Locale = user.Locale;
                  token.FamilyName = user.FamilyName;
                  token.GivenName = user.GivenName;
                  token.Id = user.Id;
                  token.Profile = user.Profile;
                  token.Scopes = scopes;
                  return token;
              }
          }

          private static UserInfo GetUserInfo(string accessToken)
          {
              var request = (HttpWebRequest)WebRequest.Create(UserInfoEndpoint);
              request.Method = "GET";
              request.Headers.Add(string.Format("Authorization: Bearer {0}", accessToken));
              var response = request.GetResponse();
              using (var stream = response.GetResponseStream())
              {
                  return Deserialize<UserInfo>(stream);
              }
          }

          private static string BuildScopes(string[] scopes)
          {
              string scope = null;
              if (scopes != null)
              {
                  foreach (var sc in scopes)
                  {
                      scope += "%20" + Uri.EscapeDataString(sc);
                  }
              }
              return scope;
          }

          // https://developers.google.com/+/web/api/rest/openidconnect/getOpenIdConnect
          [DataContract]
          private class UserInfo
          {
              [DataMember(Name = "name")]
              public string Name { get; set; }

              [DataMember(Name = "kind")]
              public string Kind { get; set; }

              [DataMember(Name = "email")]
              public string Email { get; set; }

              [DataMember(Name = "picture")]
              public string Picture { get; set; }

              [DataMember(Name = "locale")]
              public string Locale { get; set; }

              [DataMember(Name = "family_name")]
              public string FamilyName { get; set; }

              [DataMember(Name = "given_name")]
              public string GivenName { get; set; }

              [DataMember(Name = "sub")]
              public string Id { get; set; }

              [DataMember(Name = "profile")]
              public string Profile { get; set; }

              [DataMember(Name = "gender")]
              public string Gender { get; set; }
          }
      }
  }

This code uses an embedded Internet Explorer control, but since Google doesn't support old Internet Explorer versions, it's likely that you also need to add some code to play with IE's compatibility features as described here for example: https://stackoverflow.com/a/28626667/403671

You can put that code in App.xaml.cs, like this:

public partial class App : Application
{
    public App()
    {
        // use code from here: https://stackoverflow.com/a/28626667/403671
        SetWebBrowserFeatures();
    }
}

Note what the webbrowser will display depends entirely on Google, and it can considerably vary depending on the environment like cookies, language, etc.

enter image description here

Simon Mourier
  • 132,049
  • 21
  • 248
  • 298
  • You don't need to use http listener, since you are using embedded browser. Also you need to exchange code for access token on server (in OPs case at least). Maybe it's just an example, but it's not stated anywhere. Also, since OP needs authentication - it's better to use open id connect (which you might be using already, hard to tell). It will return "id_token" together with (useless for OP) access token, and id token will contain all info OP needs. – Evk Jan 26 '18 at 14:07
  • 1
    @evk - 1) we need to listen because the browser will be redirected to localhost (per app configuration, I used the same as in the google's sample), 2) read the code, and 3) the code works fine w/o open id stuff, I don't see why I should bother with it. – Simon Mourier Jan 26 '18 at 14:51
  • 1. They use external browser as you said yourself. You are using embedded and can intercept redirect to any endpoint and extract code from there. With unnecessary HttpListener your app requires admin privileges I suppose (or configuring permissions), though I'm not sure about that. 2. I read and don't see you state that this step should be performed on OPs server and not client anywhere. 3. Well it was created for some reason, for people to use it for authentication, which is what OP needs, so why use other tool which was not designed for that? It costs almost no effort to use right one. – Evk Jan 26 '18 at 15:19
  • @SimonMourier - Thanks for the answer first. But basically what I want is something much simpler. I just want google to send me some boolean indicating if authentication was successful or not. Also, isn't embedded browser a huge security risk here? – Gab Jan 29 '18 at 16:02
  • @gab - Not more than a standard browser. Not sure what you mean by that. – Simon Mourier Jan 29 '18 at 17:06
  • this is now not working. SIgn page still displayed but once you submitted the sign in form google will throw error saying that browser is not supported. – Anonymous Duck Jul 07 '20 at 05:41
  • @Sherlock - You must force the embedded IE control to behave as IE11. I've updated the code. – Simon Mourier Jul 07 '20 at 06:55
  • cool thanks dude! I'll give it a try. So this is always using IE browser? Any chance to use browser based on the default browser set in OS? – Anonymous Duck Jul 07 '20 at 14:45
  • @SimonMourier upvoted. Tested and its working. One question though since we are dealing with registry, does this mean that we need admin access to run this? – Anonymous Duck Jul 07 '20 at 15:02
  • 1
    @Sherlock - Nope, check the SetWebBrowserFeatures code, it uses HKCU to write. As for embedding IE, well, you can change the code and use any browser you like that is capable of being embedded. I just thought IE was easier and integrated. Others use CEF I believe. – Simon Mourier Jul 07 '20 at 15:43
  • @SimonMourier encountered another issue. There are accounts that cannot proceed with login, google throwing "This browser or app may not be secure." message. And it seems that this cannot be remmediated in code level. Do you have thoughts for this? – Anonymous Duck Jul 20 '20 at 09:38
  • @Sherlock - not sure what it means. I can't reproduce that. – Simon Mourier Jul 20 '20 at 14:24
  • @SimonMourier It might be a problem on the google credentials, I only encountered the error when I use my own client id. WHen using the client id in your example its working fine. Do i need to make my app google verified for it to work? – Anonymous Duck Jul 20 '20 at 14:35
  • These samples work by putting the client secret inside the app. You can't do that with a public app. I don't know why Google is demoing this workflow when it will lead to a massive security problem where the app leaks the client secret. – Christian Findlay Oct 16 '21 at 04:19
  • Did you get this app verified by Google? I tried submitting a video, but got rejected because the sample code uses localhost, and there is no way to change it. Nonetheless, they respond I need to change it. – RonaldPaguay Mar 01 '23 at 19:19
  • "Verified by google" certainly not, but it has never been, not even sure it means something, Google doesn't verify apps. Many things have changed in the browser + authentication world since the initial post. In general authentication doesn't work in embedded browsers any more. I keep it here more for history. – Simon Mourier Mar 01 '23 at 19:21
2

I think you are missing the point on what the OAuth is, start by reading this and here is a simple description of the flow.

which will just make sure that user can also login with his Google e-mail-password pair, I don't need a returned token for the further use

There is no api for user/pass validation, and the token is the whole point of the OAuth. A user with existing email should be able to login if indeed you registered your application with google. Also i see no point in prior validation of user/pass, just add the button.

igorc
  • 2,024
  • 2
  • 17
  • 29
  • I mean I will never use the token returned, I will just check if authentication was successfull and in that case make application session. – Gab Jan 29 '18 at 16:04
  • @Gab just think about it from security point of view. With web view and token your app has no information about user credentials. and google don't want you to know about it. So they provided a way to identify users base on token. – Ivan Chepikov Jan 30 '18 at 15:34
  • Moreover, Google would ask your user first does he really trust your app, with a list of permissions your app to be granted on positive choice. – Yury Schkatula Jan 31 '18 at 20:50