- Added JWT configuration to appsettings.json for secure token handling. - Updated config.json to include OAuth provider details for Microsoft, Google, and PocketId. - Added Microsoft icon SVG for UI representation. - Refactored app.config.ts to use a custom AuthInterceptor for managing access tokens. - Enhanced auth route guard to handle asynchronous authentication checks. - Created new auth models for structured request and response handling. - Developed a callback component to manage user login states and transitions. - Updated side-login component to support multiple OAuth providers with loading states. - Implemented authentication service methods for handling OAuth login flows and token management. - Added error handling and user feedback for authentication processes.
507 lines
24 KiB
C#
507 lines
24 KiB
C#
using Api.Models;
|
|
using Api.Models.DTOs;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
|
|
namespace Api.Services
|
|
{
|
|
public interface IOAuthValidationService
|
|
{
|
|
Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateIdTokenAsync(string idToken, string provider);
|
|
(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractUserInfo(ClaimsPrincipal principal, string provider);
|
|
Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractUserInfoAsync(ClaimsPrincipal principal, string provider, string? idToken = null, string? accessToken = null);
|
|
}
|
|
|
|
public class OAuthValidationService : IOAuthValidationService
|
|
{
|
|
private readonly OAuthProviderSettings _providerSettings;
|
|
private readonly ILogger<OAuthValidationService> _logger;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly JwtSecurityTokenHandler _tokenHandler;
|
|
|
|
public OAuthValidationService(
|
|
IConfiguration configuration,
|
|
ILogger<OAuthValidationService> logger,
|
|
HttpClient httpClient)
|
|
{
|
|
_providerSettings = configuration.GetSection("OAuth").Get<OAuthProviderSettings>()
|
|
?? throw new InvalidOperationException("OAuth provider settings not configured");
|
|
_logger = logger;
|
|
_httpClient = httpClient;
|
|
_tokenHandler = new JwtSecurityTokenHandler();
|
|
}
|
|
|
|
public async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateIdTokenAsync(string idToken, string provider)
|
|
{
|
|
try
|
|
{
|
|
if (!_providerSettings.Providers.TryGetValue(provider.ToLowerInvariant(), out var config))
|
|
{
|
|
return (false, null, $"Unsupported OAuth provider: {provider}");
|
|
}
|
|
|
|
return provider.ToLowerInvariant() switch
|
|
{
|
|
"microsoft" => await ValidateMicrosoftTokenAsync(idToken, config),
|
|
"google" => await ValidateGoogleTokenAsync(idToken, config),
|
|
"pocketid" => await ValidatePocketIdTokenAsync(idToken, config),
|
|
_ => (false, null, $"Provider {provider} validation not implemented")
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error validating {Provider} ID token", provider);
|
|
return (false, null, "Token validation failed due to internal error");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateMicrosoftTokenAsync(string idToken, ProviderConfig config)
|
|
{
|
|
try
|
|
{
|
|
// For Microsoft, we need to validate against their OIDC discovery document
|
|
var discoveryUrl = $"{config.Authority}/.well-known/openid-configuration";
|
|
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
|
|
|
if (discovery == null)
|
|
{
|
|
return (false, null, "Failed to retrieve Microsoft discovery document");
|
|
}
|
|
|
|
var validationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
|
ValidateIssuer = true,
|
|
ValidIssuer = discovery.Issuer,
|
|
ValidateAudience = true,
|
|
ValidAudiences = config.ValidAudiences,
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(5)
|
|
};
|
|
|
|
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
|
return (true, principal, null);
|
|
}
|
|
catch (SecurityTokenException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Microsoft token validation failed");
|
|
return (false, null, "Invalid Microsoft ID token");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateGoogleTokenAsync(string idToken, ProviderConfig config)
|
|
{
|
|
try
|
|
{
|
|
// For Google, use their discovery document
|
|
var discoveryUrl = "https://accounts.google.com/.well-known/openid-configuration";
|
|
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
|
|
|
if (discovery == null)
|
|
{
|
|
return (false, null, "Failed to retrieve Google discovery document");
|
|
}
|
|
|
|
var validationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
|
ValidateIssuer = true,
|
|
ValidIssuers = new[] { "https://accounts.google.com", "accounts.google.com" },
|
|
ValidateAudience = true,
|
|
ValidAudiences = config.ValidAudiences,
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(5)
|
|
};
|
|
|
|
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
|
return (true, principal, null);
|
|
}
|
|
catch (SecurityTokenException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Google token validation failed");
|
|
return (false, null, "Invalid Google ID token");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidatePocketIdTokenAsync(string idToken, ProviderConfig config)
|
|
{
|
|
try
|
|
{
|
|
var discoveryUrl = $"{config.Authority}/.well-known/openid-configuration";
|
|
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
|
|
|
if (discovery == null)
|
|
{
|
|
return (false, null, "Failed to retrieve PocketId discovery document");
|
|
}
|
|
|
|
var validationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
|
ValidateIssuer = true,
|
|
ValidIssuer = discovery.Issuer,
|
|
ValidateAudience = true,
|
|
ValidAudiences = config.ValidAudiences,
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(5)
|
|
};
|
|
|
|
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
|
return (true, principal, null);
|
|
}
|
|
catch (SecurityTokenException ex)
|
|
{
|
|
_logger.LogWarning(ex, "PocketId token validation failed");
|
|
return (false, null, "Invalid PocketId token");
|
|
}
|
|
}
|
|
|
|
private async Task<DiscoveryDocument?> GetDiscoveryDocumentAsync(string discoveryUrl)
|
|
{
|
|
try
|
|
{
|
|
var response = await _httpClient.GetStringAsync(discoveryUrl);
|
|
return JsonSerializer.Deserialize<DiscoveryDocument>(response, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to retrieve discovery document from {DiscoveryUrl}", discoveryUrl);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task<IEnumerable<SecurityKey>> GetSigningKeysAsync(string jwksUri)
|
|
{
|
|
try
|
|
{
|
|
var response = await _httpClient.GetStringAsync(jwksUri);
|
|
var jwks = new JsonWebKeySet(response);
|
|
return jwks.Keys;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to retrieve signing keys from {JwksUri}", jwksUri);
|
|
return Enumerable.Empty<SecurityKey>();
|
|
}
|
|
}
|
|
|
|
public (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractUserInfo(ClaimsPrincipal principal, string provider)
|
|
{
|
|
return provider.ToLowerInvariant() switch
|
|
{
|
|
"microsoft" => ExtractMicrosoftUserInfo(principal),
|
|
"google" => ExtractGoogleUserInfo(principal),
|
|
"pocketid" => ExtractPocketIdUserInfo(principal),
|
|
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
|
};
|
|
}
|
|
|
|
public async Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractUserInfoAsync(ClaimsPrincipal principal, string provider, string? idToken = null, string? accessToken = null)
|
|
{
|
|
return provider.ToLowerInvariant() switch
|
|
{
|
|
"microsoft" => await ExtractMicrosoftUserInfoAsync(principal, idToken, accessToken),
|
|
"google" => ExtractGoogleUserInfo(principal),
|
|
"pocketid" => ExtractPocketIdUserInfo(principal),
|
|
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
|
};
|
|
}
|
|
|
|
private async Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractMicrosoftUserInfoAsync(ClaimsPrincipal principal, string? idToken = null, string? accessToken = null)
|
|
{
|
|
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
|
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
|
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
|
var providerId = principal.FindFirst("sub")?.Value ?? principal.FindFirst("oid")?.Value ?? "";
|
|
var profilePicture = principal.FindFirst("picture")?.Value;
|
|
|
|
// Log available claims for debugging
|
|
_logger.LogInformation("Microsoft token claims: {Claims}",
|
|
string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")));
|
|
|
|
// Try to get additional info from Microsoft Graph using access token
|
|
if ((string.IsNullOrEmpty(firstName) || string.IsNullOrEmpty(lastName) || string.IsNullOrEmpty(profilePicture)))
|
|
{
|
|
string? tokenToUse = null;
|
|
|
|
// Prefer access token over ID token for Microsoft Graph API calls
|
|
if (!string.IsNullOrEmpty(accessToken))
|
|
{
|
|
tokenToUse = accessToken;
|
|
_logger.LogInformation("Using access token for Microsoft Graph API calls");
|
|
}
|
|
else if (!string.IsNullOrEmpty(idToken))
|
|
{
|
|
tokenToUse = idToken;
|
|
_logger.LogWarning("Using ID token for Microsoft Graph API calls (access token preferred)");
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(tokenToUse))
|
|
{
|
|
try
|
|
{
|
|
var graphProfile = await GetMicrosoftGraphProfileAsync(tokenToUse);
|
|
if (graphProfile != null)
|
|
{
|
|
firstName ??= graphProfile.GivenName;
|
|
lastName ??= graphProfile.Surname;
|
|
email = string.IsNullOrEmpty(email) ? (graphProfile.Mail ?? graphProfile.UserPrincipalName ?? email) : email;
|
|
|
|
// Try to get profile picture
|
|
if (string.IsNullOrEmpty(profilePicture))
|
|
{
|
|
profilePicture = await GetMicrosoftGraphProfilePictureUrlAsync(tokenToUse);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to get additional user info from Microsoft Graph using {TokenType}",
|
|
!string.IsNullOrEmpty(accessToken) ? "access token" : "ID token");
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we still don't have name information from the token, try to extract from other claims
|
|
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
|
|
{
|
|
// Try the 'name' claim first
|
|
var name = principal.FindFirst("name")?.Value ?? principal.FindFirst(ClaimTypes.Name)?.Value;
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
var nameParts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (nameParts.Length >= 2)
|
|
{
|
|
firstName = nameParts[0];
|
|
lastName = string.Join(" ", nameParts.Skip(1));
|
|
}
|
|
else if (nameParts.Length == 1)
|
|
{
|
|
firstName = nameParts[0];
|
|
}
|
|
}
|
|
else if (!string.IsNullOrEmpty(email))
|
|
{
|
|
// Extract name from email as fallback
|
|
var emailName = email.Split('@')[0];
|
|
var emailParts = emailName.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
|
if (emailParts.Length >= 2)
|
|
{
|
|
firstName = FormatNameFromEmail(emailParts[0]);
|
|
lastName = FormatNameFromEmail(emailParts[1]);
|
|
}
|
|
else if (emailParts.Length == 1)
|
|
{
|
|
firstName = FormatNameFromEmail(emailName);
|
|
}
|
|
}
|
|
}
|
|
|
|
return (email, firstName, lastName, profilePicture, providerId);
|
|
}
|
|
|
|
private async Task<MicrosoftGraphProfile?> GetMicrosoftGraphProfileAsync(string token)
|
|
{
|
|
try
|
|
{
|
|
// Try using the token as Authorization Bearer
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
|
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var response = await _httpClient.SendAsync(request);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("Microsoft Graph API call failed with status {StatusCode}: {ReasonPhrase}",
|
|
response.StatusCode, response.ReasonPhrase);
|
|
return null;
|
|
}
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync();
|
|
|
|
var profile = JsonSerializer.Deserialize<MicrosoftGraphProfile>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
_logger.LogInformation("Successfully retrieved Microsoft Graph profile: {DisplayName}", profile?.DisplayName);
|
|
return profile;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to get Microsoft Graph profile");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task<string?> GetMicrosoftGraphProfilePictureUrlAsync(string token)
|
|
{
|
|
try
|
|
{
|
|
// Try to get the photo directly - 404 is normal if user has no photo
|
|
using var photoRequest = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photo/$value");
|
|
photoRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
|
|
var photoResponse = await _httpClient.SendAsync(photoRequest);
|
|
|
|
if (photoResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
_logger.LogDebug("User has no profile picture in Microsoft Graph (404 - normal behavior)");
|
|
return null;
|
|
}
|
|
|
|
if (!photoResponse.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogDebug("Microsoft Graph photo API call failed with status {StatusCode}: {ReasonPhrase}",
|
|
photoResponse.StatusCode, photoResponse.ReasonPhrase);
|
|
return null;
|
|
}
|
|
|
|
// Get the photo bytes and convert to base64 data URL
|
|
var photoBytes = await photoResponse.Content.ReadAsByteArrayAsync();
|
|
|
|
// Check if we got valid image data
|
|
if (photoBytes == null || photoBytes.Length == 0)
|
|
{
|
|
_logger.LogDebug("Microsoft Graph returned empty photo data");
|
|
return null;
|
|
}
|
|
|
|
var contentType = photoResponse.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
|
|
var base64Photo = Convert.ToBase64String(photoBytes);
|
|
var dataUrl = $"data:{contentType};base64,{base64Photo}";
|
|
|
|
_logger.LogInformation("Successfully retrieved Microsoft Graph profile picture as base64 data URL ({Size} bytes, type: {ContentType})",
|
|
photoBytes.Length, contentType);
|
|
|
|
return dataUrl;
|
|
}
|
|
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
|
|
{
|
|
_logger.LogDebug("User has no profile picture in Microsoft Graph (404)");
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to get Microsoft Graph profile picture");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractMicrosoftUserInfo(ClaimsPrincipal principal)
|
|
{
|
|
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
|
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
|
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
|
var providerId = principal.FindFirst("sub")?.Value ?? principal.FindFirst("oid")?.Value ?? "";
|
|
var profilePicture = principal.FindFirst("picture")?.Value;
|
|
|
|
// Log available claims for debugging
|
|
_logger.LogInformation("Microsoft token claims: {Claims}",
|
|
string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")));
|
|
|
|
// If we don't have name information from the token, try to extract from other claims
|
|
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
|
|
{
|
|
// Try the 'name' claim first
|
|
var name = principal.FindFirst("name")?.Value ?? principal.FindFirst(ClaimTypes.Name)?.Value;
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
var nameParts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (nameParts.Length >= 2)
|
|
{
|
|
firstName = nameParts[0];
|
|
lastName = string.Join(" ", nameParts.Skip(1));
|
|
}
|
|
else if (nameParts.Length == 1)
|
|
{
|
|
firstName = nameParts[0];
|
|
}
|
|
}
|
|
else if (!string.IsNullOrEmpty(email))
|
|
{
|
|
// Extract name from email as fallback
|
|
var emailName = email.Split('@')[0];
|
|
var emailParts = emailName.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
|
if (emailParts.Length >= 2)
|
|
{
|
|
firstName = FormatNameFromEmail(emailParts[0]);
|
|
lastName = FormatNameFromEmail(emailParts[1]);
|
|
}
|
|
else if (emailParts.Length == 1)
|
|
{
|
|
firstName = FormatNameFromEmail(emailName);
|
|
}
|
|
}
|
|
}
|
|
|
|
return (email, firstName, lastName, profilePicture, providerId);
|
|
}
|
|
|
|
private static string FormatNameFromEmail(string emailPart)
|
|
{
|
|
if (string.IsNullOrEmpty(emailPart)) return emailPart;
|
|
|
|
// Remove numbers and special characters, capitalize first letter
|
|
var cleaned = new string(emailPart.Where(c => char.IsLetter(c)).ToArray());
|
|
if (string.IsNullOrEmpty(cleaned)) return emailPart;
|
|
|
|
return char.ToUpper(cleaned[0]) + cleaned[1..].ToLower();
|
|
}
|
|
|
|
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractGoogleUserInfo(ClaimsPrincipal principal)
|
|
{
|
|
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
|
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
|
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
|
var providerId = principal.FindFirst("sub")?.Value ?? "";
|
|
var profilePicture = principal.FindFirst("picture")?.Value;
|
|
|
|
return (email, firstName, lastName, profilePicture, providerId);
|
|
}
|
|
|
|
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractPocketIdUserInfo(ClaimsPrincipal principal)
|
|
{
|
|
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
|
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
|
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
|
var providerId = principal.FindFirst("sub")?.Value ?? "";
|
|
var profilePicture = principal.FindFirst("picture")?.Value;
|
|
|
|
return (email, firstName, lastName, profilePicture, providerId);
|
|
}
|
|
}
|
|
|
|
public class DiscoveryDocument
|
|
{
|
|
public string Issuer { get; set; } = string.Empty;
|
|
public string JwksUri { get; set; } = string.Empty;
|
|
public string AuthorizationEndpoint { get; set; } = string.Empty;
|
|
public string TokenEndpoint { get; set; } = string.Empty;
|
|
public string UserinfoEndpoint { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class MicrosoftGraphProfile
|
|
{
|
|
public string? GivenName { get; set; }
|
|
public string? Surname { get; set; }
|
|
public string? DisplayName { get; set; }
|
|
public string? Mail { get; set; }
|
|
public string? UserPrincipalName { get; set; }
|
|
public string? Id { get; set; }
|
|
public string? JobTitle { get; set; }
|
|
public string? MobilePhone { get; set; }
|
|
public string? OfficeLocation { get; set; }
|
|
public string? PreferredLanguage { get; set; }
|
|
// Note: Microsoft Graph doesn't provide direct profile picture URLs in the /me endpoint
|
|
// Profile pictures must be retrieved separately via /me/photo/$value
|
|
}
|
|
} |