Created
December 6, 2018 16:03
-
-
Save LanceMcCarthy/4cafc5fcdd64e747bc62a4d04a5a38b9 to your computer and use it in GitHub Desktop.
LoginDialog - A Live SDK authentication helper for UWP applications
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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.