Files
pas/Api/Services/OAuthValidationService.cs
Marek Lesko f34d523413 feat: Implement OAuth2 authentication with Microsoft, Google, and PocketId
- 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.
2025-11-07 19:23:21 +00:00

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