Skip to content

Instantly share code, notes, and snippets.

@LanceMcCarthy
Created December 6, 2018 16:03
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save LanceMcCarthy/4cafc5fcdd64e747bc62a4d04a5a38b9 to your computer and use it in GitHub Desktop.
Save LanceMcCarthy/4cafc5fcdd64e747bc62a4d04a5a38b9 to your computer and use it in GitHub Desktop.
LoginDialog - A Live SDK authentication helper for UWP applications
<ContentDialog
x:Class="YourUwpApp.LoginDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
SecondaryButtonText="cancel"
SecondaryButtonClick="LoginDialog_OnSecondaryButtonClick">
<Grid MinWidth="400"
MinHeight="600">
<WebView x:Name="webView"
LoadCompleted="WebView_OnLoadCompleted" />
</Grid>
</ContentDialog>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Navigation;
using Newtonsoft.Json;
namespace YourUwpApp.Dialogs
{
public sealed partial class LoginDialog : ContentDialog
{
private static readonly string _scope = "wl.emails%20wl.basic%20wl.offline_access%20wl.signin";
private string _clientId = "";
private readonly string _redirectUrl = "https://login.live.com/oauth20_desktop.srf";
private readonly string _accessTokenUrl = "https://login.live.com/oauth20_token.srf";
private readonly Uri _signInUrl = new Uri($"https://login.live.com/oauth20_authorize.srf?client_id={_clientId}&redirect_uri=https:%2F%2Flogin.live.com%2Foauth20_desktop.srf&response_type=code&scope={_scope}");
private readonly Uri _signOutUri = new Uri($"https://login.live.com/oauth20_logout.srf?client_id={_clientId}&redirect_uri=https:%2F%2Flogin.live.com%2Foauth20_desktop.srf");
public string AuthorizationCode { get; set; }
public LoginDialog(string clientId)
{
this._clientId = clientId;
this.InitializeComponent();
}
private void LoginDialog_OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
this.AuthorizationCode = "";
this.Hide();
}
private async void WebView_OnLoadCompleted(object sender, NavigationEventArgs e)
{
var url = e.Uri.AbsoluteUri;
// If the user had to manually sign in, you'll have a 'code' parameter in the URL.
// Use this to get the access and refresh tokens
if (url.Contains("code="))
{
// See here for ExtractQueryValue extension method https://github.com/jamesmcroft/WinUX-UWP-Toolkit/blob/develop/WinUX.Common/Extensions/Extensions.Url.cs
var authCode = e.Uri.ExtractQueryValue("code");
await this.RequestAuthorizationAsync(authCode);
}
else if (url.Contains("lc="))
{
// Redirect to signin page if there's a bounce
this.webView.Source = _signInUrl;
}
}
public async Task SignInAsync()
{
// Check the storage to see if there's a refresh token storef
var refreshToken = StorageHelpers.Instance.LoadToken("refresh_token");
if (!string.IsNullOrEmpty(refreshToken))
{
// there is a token stored, let's try to use it. This will log the user in without showing a WebView UI
await this.RequestAuthorizationAsync(refreshToken, true);
}
else
{
// If no token is available, show dialog to get user to signin and accept
await this.ShowAsync();
this.webView.Source = _signInUrl;
}
}
// Shows ContentDialog to sign out
public async Task SignOutAsync()
{
// 1 - delete any stored tokens
StorageHelpers.Instance.DeleteToken("access_token");
StorageHelpers.Instance.DeleteToken("refresh_token");
// 2 - Show the dialog
await this.ShowAsync();
// 3 - Navigate to logout page (note, this will redirect to login page once signout is complete)
this.webView.Source = _signOutUri;
}
private async Task RequestAuthorizationAsync(string authCode, bool isRefresh = false)
{
try
{
using (var client = new HttpClient())
{
// Construct the parameters
var parameters = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("redirect_uri", _redirectUrl)
};
// The last two parameters change depending on whether we're using a refresh token or not
if (isRefresh)
{
parameters.Add(new KeyValuePair<string, string>("grant_type", "refresh_token"));
parameters.Add(new KeyValuePair<string, string>("refresh_token", authCode.Split('&')[0]));
}
else
{
parameters.Add(new KeyValuePair<string, string>("grant_type", "authorization_code"));
parameters.Add(new KeyValuePair<string, string>("code", authCode.Split('&')[0]));
}
// Variable to hold the response data
var responseTxt = "";
// Post the Form data
using (var response = await client.PostAsync(new Uri(_accessTokenUrl), new FormUrlEncodedContent(parameters)))
{
// Read the response
responseTxt = await response.Content.ReadAsStringAsync();
}
// Deserialize the parameters from the response
var tokenData = JsonConvert.DeserializeObject<Dictionary<string, string>>(responseTxt);
if (tokenData.ContainsKey("access_token"))
{
// -- IMPORTANT -- //
// Store the tokens securely, I am using Rijndael encryption, but you could use PasswordVault if it's easier
StorageHelpers.Instance.StoreToken("access_token", tokenData["access_token"]);
StorageHelpers.Instance.StoreToken("refresh_token", tokenData["refresh_token"]);
// We need to prefix the access token with the token type for the auth header, this is usually "Bearer".
var tokenType = tokenData["token_type"];
var parsedAccessToken = tokenData["access_token"].Split('&')[0];
// Set the public property so that the LoginDialog caller can use it
this.AuthorizationCode = $"{tokenType} {parsedAccessToken}";
}
else
{
// Always set the Authorization code to null if there was a problem.
this.AuthorizationCode = null;
}
}
}
catch (Exception e)
{
this.AuthorizationCode = null;
}
finally
{
this.Hide();
}
}
}
}
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;
// This is truncated for security reason,
public class StorageHelpers
{
private static StorageHelpers _instance;
public static StorageHelpers Instance => _instance ?? (_instance = new StorageHelpers());
private readonly string _appDataFolder;
private readonly byte[] _symmetricKey;
private readonly byte[] _initializationVector;
public StorageHelpers()
{
_appDataFolder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var keyGenerator = new Rfc2898DeriveBytes("YOUR_KEY", Encoding.ASCII.GetBytes("YOUR_KEY"));
_symmetricKey = keyGenerator.GetBytes(32);
_initializationVector = keyGenerator.GetBytes(16);
}
public bool StoreToken(string key, string value)
{
try
{
var filePath = Path.Combine(_appDataFolder, $"{key}.txt");
var encryptedToken = EncryptString(value);
File.WriteAllText(filePath, encryptedToken);
return true;
}
catch (Exception e)
{
return false;
}
}
public string LoadToken(string key)
{
try
{
var filePath = Path.Combine(_appDataFolder, $"{key}.txt");
if (File.Exists(filePath))
{
var storedValue = File.ReadAllText(filePath);
return DecryptString(storedValue);
}
else
{
return null;
}
}
catch (Exception e)
{
return null;
}
}
private string EncryptString(string inputText)
{
var textBytes = Encoding.Unicode.GetBytes(inputText);
var encryptedBytes = EncryptBytes(textBytes);
Debug.WriteLine($"EncryptString complete: {encryptedBytes.Length} bytes");
return Convert.ToBase64String(encryptedBytes);
}
private string DecryptString(string encryptedText)
{
// NOTE: This string is encrypted first, THEN converted to Base64 (not just obfuscated as Base64)
var encryptedBytes = Convert.FromBase64String(encryptedText);
var decryptedBytes = DecryptBytes(encryptedBytes);
return Encoding.Unicode.GetString(decryptedBytes, 0, decryptedBytes.Length);
}
private byte[] EncryptBytes(byte[] unencryptedData)
{
// I chose Rijndael instead of AES because of it's support for larger block size (AES only support 128)
using (var cipher = new RijndaelManaged { Key = _symmetricKey, IV = _initializationVector })
using (var cryptoTransform = cipher.CreateEncryptor())
using (var memoryStream = new MemoryStream())
using (var cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Write))
{
cryptoStream.Write(unencryptedData, 0, unencryptedData.Length);
cryptoStream.FlushFinalBlock();
var encryptedBytes = memoryStream.ToArray();
Debug.WriteLine($"EncryptBytes complete: {encryptedBytes.Length} bytes");
return encryptedBytes;
}
}
private byte[] DecryptBytes(byte[] encryptedBytes)
{
using (var cipher = new RijndaelManaged())
using (var cryptoTransform = cipher.CreateDecryptor(_symmetricKey, _initializationVector))
using (var memoryStream = new MemoryStream(encryptedBytes))
using (var cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Read))
{
byte[] decryptedBytes = new byte[encryptedBytes.Length];
int bytesRead = cryptoStream.Read(decryptedBytes, 0, decryptedBytes.Length);
// Note - I'm using Take() to clean up junk bytes at the end of the array
return decryptedBytes.Take(bytesRead).ToArray();
}
}
}
@LanceMcCarthy
Copy link
Author

Important Notes and FAQ

- Storage Helper Encryption
If you use my encryption methods, make sure you generate a unique password and salt to replace "YOUR_KEY". Otherwise your encryption level will be laughably easy to crack.

var keyGenerator = new Rfc2898DeriveBytes("YOUR_KEY", Encoding.ASCII.GetBytes("YOUR_KEY"));

As mentioned in the accompanying blog post here, you could use the PasswordVault instead. Just keep in ind that most uses have their password vaults roamed, so that the same tokens might be used across PCs. This may cause a 401 from an API, in which case, have the user sign in again.

- Where do you get a ClientID?

This would have been part of you signing up for the API access. It is what you get from the Live SDK or Microsoft App portal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment