//-----------------------------------------------------------------------
//
// 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;
}
}