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.
This commit is contained in:
139
Api/Services/JwtService.cs
Normal file
139
Api/Services/JwtService.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateToken(User user);
|
||||
ClaimsPrincipal? ValidateToken(string token);
|
||||
DateTime GetTokenExpiration(string token);
|
||||
}
|
||||
|
||||
public class JwtService : IJwtService
|
||||
{
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
private readonly ILogger<JwtService> _logger;
|
||||
private readonly SymmetricSecurityKey _key;
|
||||
|
||||
public JwtService(IConfiguration configuration, ILogger<JwtService> logger)
|
||||
{
|
||||
_jwtSettings = configuration.GetSection("Jwt").Get<JwtSettings>()
|
||||
?? throw new InvalidOperationException("JWT settings not configured");
|
||||
_logger = logger;
|
||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
|
||||
}
|
||||
|
||||
public string GenerateToken(User user)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim("jti", Guid.NewGuid().ToString()),
|
||||
new Claim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
|
||||
};
|
||||
|
||||
// Add optional claims if available
|
||||
if (!string.IsNullOrEmpty(user.FirstName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.GivenName, user.FirstName));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.LastName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Surname, user.LastName));
|
||||
}
|
||||
|
||||
// Add provider information
|
||||
var providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList();
|
||||
if (providers.Any())
|
||||
{
|
||||
claims.Add(new Claim("providers", string.Join(",", providers)));
|
||||
}
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes),
|
||||
SigningCredentials = credentials,
|
||||
Issuer = _jwtSettings.Issuer,
|
||||
Audience = _jwtSettings.Audience
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating JWT token for user {UserId}", user.Id);
|
||||
throw new InvalidOperationException("Failed to generate access token", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = _key,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _jwtSettings.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _jwtSettings.Audience,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
|
||||
};
|
||||
|
||||
var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||
|
||||
// Ensure the token is a JWT token with the correct algorithm
|
||||
if (validatedToken is not JwtSecurityToken jwtToken ||
|
||||
!jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal;
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid token validation attempt");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating JWT token");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime GetTokenExpiration(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var jsonToken = tokenHandler.ReadJwtToken(token);
|
||||
return jsonToken.ValidTo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reading token expiration");
|
||||
throw new InvalidOperationException("Invalid token format", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
507
Api/Services/OAuthValidationService.cs
Normal file
507
Api/Services/OAuthValidationService.cs
Normal file
@@ -0,0 +1,507 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user