Using cached Cognito identity from Xamarin

342 views Asked by At

When I first log into my app, I go through the following code:

     auth = new Xamarin.Auth.OAuth2Authenticator(
        "my-google-client-id.apps.googleusercontent.com",
        string.Empty,
        "openid",
        new System.Uri("https://accounts.google.com/o/oauth2/v2/auth"),
        new System.Uri("com.enigmadream.storyvoque:/oauth2redirect"),
        new System.Uri("https://www.googleapis.com/oauth2/v4/token"),
        isUsingNativeUI: true);

     auth.Completed += Auth_Completed;
     StartActivity(auth.GetUI(this));

Which triggers this activity:

[Activity(Label = "GoodleAuthInterceptor")]
[IntentFilter(actions: new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
  DataSchemes = new[] { "com.enigmadream.storyvoque" }, DataPaths = new[] { "/oauth2redirect" })]
public class GoodleAuthInterceptor : Activity
{
  protected override void OnCreate(Bundle savedInstanceState)
  {
     base.OnCreate(savedInstanceState);
     Android.Net.Uri uri_android = Intent.Data;
     Uri uri_netfx = new Uri(uri_android.ToString());
     MainActivity.auth?.OnPageLoading(uri_netfx);
     Finish();
  }
}

And finally this code to link the account to Cognito:

  private void Auth_Completed(object sender, Xamarin.Auth.AuthenticatorCompletedEventArgs e)
  {
     if (e.IsAuthenticated)
     {
        var idToken = e.Account.Properties["id_token"];

        credentials.AddLogin("accounts.google.com", idToken);
        AmazonCognitoIdentityClient cli = new AmazonCognitoIdentityClient(credentials, RegionEndpoint.USEast2);
        var req = new Amazon.CognitoIdentity.Model.GetIdRequest();
        req.Logins.Add("accounts.google.com", idToken);
        req.IdentityPoolId = "us-east-2:79ebf8e1-97de-4d1c-959a-xxxxxxxxxxxx";
        cli.GetIdAsync(req).ContinueWith((task) =>
        {
           if ((task.Status == TaskStatus.RanToCompletion) && (task.Result != null))
           {
              ShowMessage(string.Format("Identity {0} retrieved", task.Result.IdentityId));
           }
           else
              ShowMessage(task.Exception.InnerException != null ? task.Exception.InnerException.Message : task.Exception.Message);
        });
     }
     else
        ShowMessage("Login cancelled");
  }

This all works great, and after the login, I am able to use my identity/credentials to retrieve data from DynamoDB. With this object:

Amazon.DynamoDBv2.AmazonDynamoDBClient ddbc = new Amazon.DynamoDBv2.AmazonDynamoDBClient(credentials, RegionEndpoint.USEast2);

The second time I run my app, this code runs:

if (!string.IsNullOrEmpty(credentials.GetCachedIdentityId()) || credentials.CurrentLoginProviders.Length > 0)
{
   if (!bDidLogin)
   {
      var idToken = credentials.GetIdentityId();
      ShowMessage(string.Format("I still remember you're {0} ", idToken));

And if I try to use the credentials with DynamoDB (or anything, I assume) at this point, I get errors that I don't have access to the identity. I have to logout (credentials.Clear()) and login again to obtain proper credentials. I could require that a user go through the whole login process every time my app runs, but that's a real pain because the Google login process requires the user to know how to manually close the web browser to get back to the application after authenticating. Is there something I'm missing about the purpose and usage of cached credentials? When I use most apps, they aren't requiring me to log into my Google account every time and close a web browser just to access their server resources.

2

There are 2 answers

0
BlueMonkMN On BEST ANSWER

It looks like the refresh token needs to be submitted back to the OAuth2 provider to get an updated id token to add to the credentials object. First I added some code to save and load the refresh_token in a config.json file:

private Dictionary<string, string> config;
const string CONFIG_FILE = "config.json";

private void Auth_Completed(object sender, Xamarin.Auth.AuthenticatorCompletedEventArgs e)
{
   if (e.IsAuthenticated)
   {
      var idToken = e.Account.Properties["id_token"];
      if (e.Account.Properties.ContainsKey("refresh_token"))
      {
         if (config == null)
            config = new Dictionary<string, string>();
         config["refresh_token"] = e.Account.Properties["refresh_token"];
         WriteConfig();
      }

      credentials.AddLogin("accounts.google.com", idToken);
      CognitoLogin(idToken).ContinueWith((t) =>
      {
         try
         {
            t.Wait();
         }
         catch (Exception ex)
         {
            ShowMessage(ex.Message);
         }
      });
   }
   else
      ShowMessage("Login cancelled");
}

void WriteConfig()
{
   using (var configWriter = new System.IO.StreamWriter(
      Application.OpenFileOutput(CONFIG_FILE, Android.Content.FileCreationMode.Private)))
   {
      configWriter.Write(ThirdParty.Json.LitJson.JsonMapper.ToJson(config));
      configWriter.Close();
   }
}

public void Login()
{
   try
   {
      if (!string.IsNullOrEmpty(credentials.GetCachedIdentityId()) || credentials.CurrentLoginProviders.Length > 0)
      {
         if (!bDidLogin)
         {
            var idToken = credentials.GetIdentityId();
            if (ReadConfig())
            {
               LoginRefreshAsync().ContinueWith((t) =>
               {
                  try
                  {
                     t.Wait();
                     if (!t.Result)
                        FullLogin();
                  }
                  catch (Exception ex)
                  {
                     ShowMessage(ex.Message);
                  }
               });
            }
            else
            {
               credentials.Clear();
               FullLogin();
            }
         }
      }
      else
         FullLogin();
      bDidLogin = true;
   }
   catch(Exception ex)
   {
      ShowMessage(string.Format("Error logging in: {0}", ex.Message));
   }
}

private bool ReadConfig()
{
   bool bFound = false;
   foreach (string filename in Application.FileList())
      if (string.Compare(filename, CONFIG_FILE, true) == 0)
      {
         bFound = true;
         break;
      }
   if (!bFound)
      return false;
   using (var configReader = new System.IO.StreamReader(Application.OpenFileInput(CONFIG_FILE)))
   {
      config = ThirdParty.Json.LitJson.JsonMapper.ToObject<Dictionary<string, string>>(configReader.ReadToEnd());
      return true;
   }
}

Then refactored the code that initiates the interactive login into a separate function:

public void FullLogin()
{
   auth = new Xamarin.Auth.OAuth2Authenticator(CLIENTID_GOOGLE, string.Empty, "openid",
      new Uri("https://accounts.google.com/o/oauth2/v2/auth"),
      new Uri("com.enigmadream.storyvoque:/oauth2redirect"),
      new Uri("https://accounts.google.com/o/oauth2/token"),
      isUsingNativeUI: true);

   auth.Completed += Auth_Completed;
   StartActivity(auth.GetUI(this));
}

Refactored the code that retrieves a Cognito identity into its own function:

private async Task CognitoLogin(string idToken)
{
   AmazonCognitoIdentityClient cli = new AmazonCognitoIdentityClient(credentials, RegionEndpoint.USEast2);
   var req = new Amazon.CognitoIdentity.Model.GetIdRequest();
   req.Logins.Add("accounts.google.com", idToken);
   req.IdentityPoolId = ID_POOL;
   try
   {
      var result = await cli.GetIdAsync(req);
      ShowMessage(string.Format("Identity {0} retrieved", result.IdentityId));
   }
   catch (Exception ex)
   {
      ShowMessage(ex.Message);
   }
}

And finally implemented a function that can retrieve a new token based on the refresh token, insert it into the current Cognito credentials, and get an updated Cognito identity.

private async Task<bool> LoginRefreshAsync()
{
   string tokenUrl = "https://accounts.google.com/o/oauth2/token";
   try
   {
      using (System.Net.Http.HttpClient client = new System.Net.Http.HttpClient())
      {
         string contentString = string.Format(
            "client_id={0}&grant_type=refresh_token&refresh_token={1}&",
            Uri.EscapeDataString(CLIENTID_GOOGLE),
            Uri.EscapeDataString(config["refresh_token"]));
         System.Net.Http.HttpContent content = new System.Net.Http.ByteArrayContent(
            System.Text.Encoding.UTF8.GetBytes(contentString));
         content.Headers.Add("content-type", "application/x-www-form-urlencoded");
         System.Net.Http.HttpResponseMessage msg = await client.PostAsync(tokenUrl, content);
         string result = await msg.Content.ReadAsStringAsync();
         string idToken = System.Json.JsonValue.Parse(result)["id_token"];
         credentials.AddLogin("accounts.google.com", idToken);
         /* EDIT -- discovered this is not necessary! */
         // await CognitoLogin(idToken);
         return true;
      }
   }
   catch (Exception ex)
   {
      ShowMessage(ex.Message);
      return false;
   }
}

I'm not sure if this is optimal or even correct, but it seems to work. I can use the resulting credentials to access DynamoDB without having to prompt the user for permission/credentials again.

0
BlueMonkMN On

There's a very different solution I'm trying to fit with the other answer. But it's so different, I'm adding it as a separate answer.

It appears the problem was not so much related to needing to explicitly use a refresh token to get an updated access token (I think this is done implicitly), but rather needing to remember the identity token. So rather than include all the complexity of manually applying a refresh token, all that's needed is to store the identity token (which can be done in a way similar to how the refresh token was being stored). Then we just need to add that same identity token back to the credentials object when it's missing.

if (!string.IsNullOrEmpty(credentials.GetCachedIdentityId()) || credentials.CurrentLoginProviders.Length > 0)
{
   if (config.Read())
   {
      if (config["id_token"] != null)
         credentials.AddLogin(currentProvider.Name, config["id_token"]);

Edit: The problem of needing to use a refresh token does still exist. This code works if the token hasn't expired, but attempting to use these credentials after the token has expired will fail, so there is still some need to use a refresh token somehow in some cases.