//----------------------------------------------------------------------- // // Copyright (c) lanedirt. All rights reserved. // Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- namespace AliasVault.Client.Services.Auth; using System.Net.Http.Json; using System.Text.Json; using AliasVault.Shared.Models.WebApi.Auth; using Blazored.LocalStorage; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; /// /// This service is responsible for handling authentication-related operations such as refreshing tokens, /// storing tokens, and revoking tokens. /// /// The HTTP client. /// The local storage service. /// IWebAssemblyHostEnvironment instance. /// Config instance. /// JSInteropService instance. public sealed class AuthService(HttpClient httpClient, ILocalStorageService localStorage, IWebAssemblyHostEnvironment environment, Config config, JsInteropService jsInteropService) { private const string AccessTokenKey = "token"; private const string RefreshTokenKey = "refreshToken"; /// /// Test string that is stored in local storage in encrypted state. This is used to validate the encryption key /// locally during future vault unlocks. /// private const string EncryptionTestString = "aliasvault-test-string"; /// /// The username of the currently logged-in user to prevent any conflicts during future vault saves. /// private string _username = string.Empty; /// /// The encryption key used to encrypt and decrypt the vault data. /// private byte[] _encryptionKey = new byte[32]; /// /// Refreshes the access token asynchronously. /// /// The new access token. public async Task RefreshTokenAsync() { var accessToken = await GetAccessTokenAsync(); var refreshToken = await GetRefreshTokenAsync(); var tokenInput = new TokenModel { Token = accessToken, RefreshToken = refreshToken }; using var request = new HttpRequestMessage(HttpMethod.Post, "v1/Auth/refresh") { Content = JsonContent.Create(tokenInput), }; // Add the X-Ignore-Failure header to the request so any failure does not trigger another refresh token request. request.Headers.Add("X-Ignore-Failure", "true"); var response = await httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { var responseContent = await response.Content.ReadAsStringAsync(); var tokenResponse = JsonSerializer.Deserialize(responseContent); if (tokenResponse != null) { // Store the token as a plain string in local storage. await StoreAccessTokenAsync(tokenResponse.Token); await StoreRefreshTokenAsync(tokenResponse.RefreshToken); return tokenResponse.Token; } } return null; } /// /// Retrieves the username of the currently logged-in user. /// /// The currently logged-in user's username. public string GetUsername() { return _username; } /// /// Stores the username of the vault owner in local memory. This value will be sent to the server during /// vault updates to ensure that the API is updating the correct vault of the correct user preventing any conflicts /// or vault corruption. /// /// The username of the currently logged-in user and owner of the vault being loaded. public void StoreUsername(string? username) { _username = username ?? string.Empty; } /// /// Retrieves the stored access token asynchronously. /// /// The stored access token. public async Task GetAccessTokenAsync() { return await localStorage.GetItemAsStringAsync(AccessTokenKey) ?? string.Empty; } /// /// Stores the new access token asynchronously. /// /// The new access token. /// A representing the asynchronous operation. public async Task StoreAccessTokenAsync(string newToken) { await localStorage.SetItemAsStringAsync(AccessTokenKey, newToken); } /// /// Get encryption key. /// /// SrpArgonEncryption key as byte[]. public byte[] GetEncryptionKey() { return _encryptionKey; } /// /// Get encryption key as base64 string. /// /// SrpArgonEncryption key as base64 string. public string GetEncryptionKeyAsBase64Async() { if (environment.IsDevelopment() && config.UseDebugEncryptionKey) { // When project runs in development mode a static encryption key will be used. // This allows to skip the unlock screen for faster development. // Use launch profile "http-release" to get the actual user flow. return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB="; } return Convert.ToBase64String(GetEncryptionKey()); } /// /// Returns whether the encryption key is set. /// /// Return true if encryption key is set, otherwise false. public bool IsEncryptionKeySet() { // Check that encryption key is set. If not, redirect to unlock screen. var encryptionKey = GetEncryptionKeyAsBase64Async(); if (encryptionKey == string.Empty || encryptionKey == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") { // SrpArgonEncryption key is empty or base64 encoded empty string. return false; } return true; } /// /// Stores the encryption key asynchronously in-memory. /// /// SrpArgonEncryption key. /// Task. public async Task StoreEncryptionKeyAsync(byte[] newKey) { _encryptionKey = newKey; // When storing a new encryption key, encrypt a test string and save it to local storage. // This test string can then be used to locally validate the password during future unlocks. var encryptedTestString = await jsInteropService.SymmetricEncrypt(EncryptionTestString, GetEncryptionKeyAsBase64Async()); // Store the encrypted test string in local storage. await localStorage.SetItemAsStringAsync("encryptionTestString", encryptedTestString); } /// /// Check if WebAuthn is enabled. /// /// True if WebAuthn is enabled, otherwise false. public async Task IsWebAuthnEnabledAsync() { await localStorage.GetItemAsStringAsync("webAuthnEnabled"); return await localStorage.GetItemAsStringAsync("webAuthnEnabled") == "true"; } /// /// Get the encryption key that is stored in local storage and decrypt it with the WebAuthn derived key. /// /// The username to associate with the credential. /// Decrypted encryption key. public async Task GetDecryptedWebAuthnEncryptionKeyAsync(string username) { var encryptedEncryptionKey = await localStorage.GetItemAsStringAsync("webAuthnEncryptedEncryptionKey"); var webauthnCredentialId = await localStorage.GetItemAsStringAsync("webAuthnCredentialId"); var webauthnSalt = await localStorage.GetItemAsStringAsync("webAuthnSalt"); if (string.IsNullOrEmpty(encryptedEncryptionKey) || string.IsNullOrEmpty(webauthnCredentialId) || string.IsNullOrEmpty(webauthnSalt)) { throw new InvalidOperationException("WebAuthn encrypted encryption key is not set or WebAuthn credential ID is not set."); } var webauthnCredentialDerivedKey = await jsInteropService.GetWebAuthnCredentialDerivedKey(webauthnCredentialId, webauthnSalt); // Decrypt the encrypted encryption key with the WebAuthn derived key. var decryptedString = await jsInteropService.SymmetricDecrypt(encryptedEncryptionKey, webauthnCredentialDerivedKey); return Convert.FromBase64String(decryptedString); } /// /// Set WebAuthn enabled. This will be used to determine if WebAuthn should be used for attempting to unlock the vault. /// If set to false, the user will be prompted to enter the master password instead. /// /// True if WebAuthn is enabled, otherwise false. /// WebAuthn credential ID. /// WebAuthn salt. /// WebAuthn credential derived key. /// Task. public async Task SetWebAuthnEnabledAsync(bool enabled, string? webauthCredentialId = null, string? webauthSalt = null, string? webauthCredentialDerivedKey = null) { await localStorage.SetItemAsStringAsync("webAuthnEnabled", enabled.ToString().ToLower()); // Encrypt the current encryption key with the webauthn derived key and store it in local storage. if (enabled && !string.IsNullOrEmpty(webauthCredentialId) && !string.IsNullOrEmpty(webauthSalt) && !string.IsNullOrEmpty(webauthCredentialDerivedKey)) { var encryptionKeyBase64 = Convert.ToBase64String(GetEncryptionKey()); var encryptedEncryptionKey = await jsInteropService.SymmetricEncrypt(encryptionKeyBase64, webauthCredentialDerivedKey); await localStorage.SetItemAsStringAsync("webAuthnCredentialId", webauthCredentialId); await localStorage.SetItemAsStringAsync("webAuthnSalt", webauthSalt); await localStorage.SetItemAsStringAsync("webAuthnEncryptedEncryptionKey", encryptedEncryptionKey); } else { // Clear the WebAuthn credential ID, salt and derived key if WebAuthn is disabled. await localStorage.RemoveItemAsync("webAuthnCredentialId"); await localStorage.RemoveItemAsync("webAuthnSalt"); await localStorage.RemoveItemAsync("webAuthnCredentialDerivedKey"); await localStorage.RemoveItemAsync("webAuthnEncryptedEncryptionKey"); } } /// /// Check if the encryption test string is stored in local storage which is used to validate /// the encryption key locally during future vault unlocks. If it's not stored the unlock /// attempts will fail and user should log in again instead. /// /// Task. public async Task HasEncryptionKeyTestStringAsync() { return await localStorage.GetItemAsStringAsync("encryptionTestString") != null; } /// /// Validate the encryption locally by attempting to decrypt test string stored in local storage. /// /// The encryption key to validate. /// True if encryption key is valid, false if not. public async Task ValidateEncryptionKeyAsync(byte[] encryptionKey) { // Get the encrypted test string from local storage. var encryptedTestString = await localStorage.GetItemAsStringAsync("encryptionTestString"); if (encryptedTestString == null) { return false; } var base64EncryptionKey = Convert.ToBase64String(encryptionKey); // Decrypt the test string using the provided encryption key. try { var decryptedTestString = await jsInteropService.SymmetricDecrypt(encryptedTestString, base64EncryptionKey); // If the decrypted test string is not equal to the test string, the encryption key is invalid. return decryptedTestString == EncryptionTestString; } catch { // Ignore errors, if decryption fails the encryption key is invalid. return false; } } /// /// Stores the new refresh token asynchronously. /// /// The new refresh token. /// A representing the asynchronous operation. public async Task StoreRefreshTokenAsync(string newToken) { await localStorage.SetItemAsStringAsync(RefreshTokenKey, newToken); } /// /// Removes the stored access and refresh tokens asynchronously, called when logging out. /// /// A representing the asynchronous operation. public async Task RemoveTokensAsync() { // Revoke the tokens from the server by calling the webapi. try { await RevokeTokenAsync(); } catch (Exception) { // If an exception occurs we ignore it and continue with removing the tokens from local storage. } // Remove the tokens from local storage. _username = string.Empty; await localStorage.RemoveItemAsync(AccessTokenKey); await localStorage.RemoveItemAsync(RefreshTokenKey); } /// /// Removes the encryption key from memory, called during logout. /// public void RemoveEncryptionKey() { _encryptionKey = new byte[32]; } /// /// Revokes the access and refresh tokens on the server asynchronously. /// /// A representing the asynchronous operation. private async Task RevokeTokenAsync() { // Remove webauthn enabled flag. await SetWebAuthnEnabledAsync(false); var tokenInput = new TokenModel { Token = await GetAccessTokenAsync(), RefreshToken = await GetRefreshTokenAsync(), }; using var request = new HttpRequestMessage(HttpMethod.Post, "v1/Auth/revoke") { Content = JsonContent.Create(tokenInput), }; // Add the X-Ignore-Failure header to the request so any failure does not trigger another refresh token request. request.Headers.Add("X-Ignore-Failure", "true"); await httpClient.SendAsync(request); } /// /// Retrieves the stored refresh token asynchronously. /// /// The stored refresh token. private async Task GetRefreshTokenAsync() { return await localStorage.GetItemAsStringAsync(RefreshTokenKey) ?? string.Empty; } }