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 _logger; private readonly HttpClient _httpClient; private readonly JwtSecurityTokenHandler _tokenHandler; public OAuthValidationService( IConfiguration configuration, ILogger logger, HttpClient httpClient) { _providerSettings = configuration.GetSection("OAuth").Get() ?? 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 GetDiscoveryDocumentAsync(string discoveryUrl) { try { var response = await _httpClient.GetStringAsync(discoveryUrl); return JsonSerializer.Deserialize(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> 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(); } } 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 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(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 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 } }