From f34d52341357920bc4e731170c87bc3cd1974d0f Mon Sep 17 00:00:00 2001 From: Marek Lesko Date: Fri, 7 Nov 2025 19:23:21 +0000 Subject: [PATCH] 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. --- Api/Api.csproj | 3 + Api/Controllers/AuthController.cs | 273 ++++++++++ ...07172421_AddUserAuthentication.Designer.cs | 189 +++++++ .../20251107172421_AddUserAuthentication.cs | 86 +++ Api/Migrations/AppDbContextModelSnapshot.cs | 102 ++++ Api/Models/AppDbContext.cs | 30 +- Api/Models/DTOs/AuthenticationDtos.cs | 59 ++ Api/Models/User.cs | 72 +++ Api/Program.cs | 85 ++- Api/Services/JwtService.cs | 139 +++++ Api/Services/OAuthValidationService.cs | 507 ++++++++++++++++++ Api/appsettings.json | 26 +- Web/public/config.json | 17 +- Web/public/images/svgs/microsoft-icon.svg | 6 + Web/src/app/app.config.ts | 12 +- Web/src/app/app.route.guard.ts | 18 +- Web/src/app/models/auth.models.ts | 35 ++ .../callback/callback.component.html | 30 ++ .../callback/callback.component.ts | 47 +- .../side-login/side-login.component.html | 22 +- .../side-login/side-login.component.ts | 47 +- Web/src/app/services/auth.interceptor.ts | 29 + .../app/services/authentication.service.ts | 339 +++++++++++- 23 files changed, 2090 insertions(+), 83 deletions(-) create mode 100644 Api/Controllers/AuthController.cs create mode 100644 Api/Migrations/20251107172421_AddUserAuthentication.Designer.cs create mode 100644 Api/Migrations/20251107172421_AddUserAuthentication.cs create mode 100644 Api/Models/DTOs/AuthenticationDtos.cs create mode 100644 Api/Models/User.cs create mode 100644 Api/Services/JwtService.cs create mode 100644 Api/Services/OAuthValidationService.cs create mode 100644 Web/public/images/svgs/microsoft-icon.svg create mode 100644 Web/src/app/models/auth.models.ts create mode 100644 Web/src/app/services/auth.interceptor.ts diff --git a/Api/Api.csproj b/Api/Api.csproj index ed7a306..027cbf5 100755 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -22,6 +22,9 @@ + + + diff --git a/Api/Controllers/AuthController.cs b/Api/Controllers/AuthController.cs new file mode 100644 index 0000000..1fd092c --- /dev/null +++ b/Api/Controllers/AuthController.cs @@ -0,0 +1,273 @@ +using Api.Models; +using Api.Models.DTOs; +using Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AuthController : ControllerBase + { + private readonly AppDbContext _context; + private readonly IOAuthValidationService _oauthValidationService; + private readonly IJwtService _jwtService; + private readonly ILogger _logger; + + public AuthController( + AppDbContext context, + IOAuthValidationService oauthValidationService, + IJwtService jwtService, + ILogger logger) + { + _context = context; + _oauthValidationService = oauthValidationService; + _jwtService = jwtService; + _logger = logger; + } + + /// + /// Authenticates a user with an OAuth ID token and returns a custom access token + /// + /// Authentication request containing ID token and provider + /// Custom access token and user information + [HttpPost("authenticate")] + public async Task> AuthenticateAsync([FromBody] AuthenticateRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + try + { + // Validate the ID token with the specified provider + var (isValid, principal, errorMessage) = await _oauthValidationService + .ValidateIdTokenAsync(request.IdToken, request.Provider); + + if (!isValid || principal == null) + { + _logger.LogWarning("Invalid ID token for provider {Provider}: {Error}", request.Provider, errorMessage); + return BadRequest(new { error = "invalid_token", message = errorMessage ?? "Invalid ID token" }); + } + + // Extract user information from the validated token + var userInfo = request.Provider.ToLowerInvariant() == "microsoft" + ? await _oauthValidationService.ExtractUserInfoAsync(principal, request.Provider, request.IdToken, request.AccessToken) + : _oauthValidationService.ExtractUserInfo(principal, request.Provider); + + if (string.IsNullOrEmpty(userInfo.Email)) + { + _logger.LogWarning("No email found in {Provider} token", request.Provider); + return BadRequest(new { error = "invalid_token", message = "Email claim is required" }); + } + + // Parse the provider enum + if (!Enum.TryParse(request.Provider, true, out var providerEnum)) + { + return BadRequest(new { error = "invalid_provider", message = $"Unsupported provider: {request.Provider}" }); + } + + // Find or create user + var (user, isNewUser) = await FindOrCreateUserAsync(userInfo, providerEnum); + + // Update last login time + user.LastLoginAt = DateTime.UtcNow; + + // Update or create OAuth provider record + await UpdateUserOAuthProviderAsync(user, userInfo, providerEnum); + + await _context.SaveChangesAsync(); + + // Generate custom access token + var accessToken = _jwtService.GenerateToken(user); + var expiresAt = _jwtService.GetTokenExpiration(accessToken); + + // Prepare response + var userProfile = new UserProfile + { + Id = user.Id, + Email = user.Email, + FirstName = user.FirstName, + LastName = user.LastName, + ProfilePictureUrl = user.ProfilePictureUrl, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + Providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList() + }; + + var response = new AuthenticateResponse + { + AccessToken = accessToken, + ExpiresAt = expiresAt, + User = userProfile, + IsNewUser = isNewUser + }; + + _logger.LogInformation("User {Email} authenticated successfully with {Provider} (New: {IsNew})", + user.Email, request.Provider, isNewUser); + + return Ok(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Authentication failed for provider {Provider}", request.Provider); + return StatusCode(500, new { error = "internal_error", message = "Authentication failed" }); + } + } + + /// + /// Gets the current user's profile information + /// + /// User profile information + [HttpGet("me")] + [Authorize] + public async Task> GetCurrentUserAsync() + { + try + { + var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId)) + { + return Unauthorized(new { error = "invalid_token", message = "User ID not found in token" }); + } + + var user = await _context.Users + .Include(u => u.OAuthProviders) + .FirstOrDefaultAsync(u => u.Id == userId && u.IsActive); + + if (user == null) + { + return NotFound(new { error = "user_not_found", message = "User not found or inactive" }); + } + + var userProfile = new UserProfile + { + Id = user.Id, + Email = user.Email, + FirstName = user.FirstName, + LastName = user.LastName, + ProfilePictureUrl = user.ProfilePictureUrl, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + Providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList() + }; + + return Ok(userProfile); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving current user profile"); + return StatusCode(500, new { error = "internal_error", message = "Failed to retrieve user profile" }); + } + } + + /// + /// Revokes the current access token (logout) + /// + /// Success message + [HttpPost("logout")] + [Authorize] + public IActionResult Logout() + { + // In a real application, you might want to maintain a blacklist of revoked tokens + // For now, we'll just return success as JWT tokens are stateless + _logger.LogInformation("User logged out"); + return Ok(new { message = "Logged out successfully" }); + } + + private async Task<(User User, bool IsNewUser)> FindOrCreateUserAsync( + (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) userInfo, + OAuthProvider provider) + { + var existingUser = await _context.Users + .Include(u => u.OAuthProviders) + .FirstOrDefaultAsync(u => u.Email == userInfo.Email && u.IsActive); + + if (existingUser != null) + { + // Update user information if it has changed + var hasChanges = false; + + if (!string.IsNullOrEmpty(userInfo.FirstName) && existingUser.FirstName != userInfo.FirstName) + { + existingUser.FirstName = userInfo.FirstName; + hasChanges = true; + } + + if (!string.IsNullOrEmpty(userInfo.LastName) && existingUser.LastName != userInfo.LastName) + { + existingUser.LastName = userInfo.LastName; + hasChanges = true; + } + + if (!string.IsNullOrEmpty(userInfo.ProfilePictureUrl) && existingUser.ProfilePictureUrl != userInfo.ProfilePictureUrl) + { + existingUser.ProfilePictureUrl = userInfo.ProfilePictureUrl; + hasChanges = true; + } + + if (hasChanges) + { + _logger.LogInformation("Updated user information for {Email}", userInfo.Email); + } + + return (existingUser, false); + } + + // Create new user + var newUser = new User + { + Email = userInfo.Email, + FirstName = userInfo.FirstName, + LastName = userInfo.LastName, + ProfilePictureUrl = userInfo.ProfilePictureUrl, + CreatedAt = DateTime.UtcNow, + IsActive = true + }; + + _context.Users.Add(newUser); + _logger.LogInformation("Created new user for {Email}", userInfo.Email); + + return (newUser, true); + } + + private async Task UpdateUserOAuthProviderAsync( + User user, + (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) userInfo, + OAuthProvider provider) + { + var existingProvider = user.OAuthProviders + .FirstOrDefault(p => p.Provider == provider); + + if (existingProvider != null) + { + // Update existing provider record + existingProvider.ProviderEmail = userInfo.Email; + existingProvider.ProviderName = $"{userInfo.FirstName} {userInfo.LastName}".Trim(); + existingProvider.LastUsedAt = DateTime.UtcNow; + } + else + { + // Create new provider record + var newProvider = new UserOAuthProvider + { + UserId = user.Id, + Provider = provider, + ProviderId = userInfo.ProviderId, + ProviderEmail = userInfo.Email, + ProviderName = $"{userInfo.FirstName} {userInfo.LastName}".Trim(), + CreatedAt = DateTime.UtcNow, + LastUsedAt = DateTime.UtcNow + }; + + user.OAuthProviders.Add(newProvider); + _logger.LogInformation("Added {Provider} OAuth provider for user {Email}", provider, user.Email); + } + + await Task.CompletedTask; // Make the method actually async + } + } +} \ No newline at end of file diff --git a/Api/Migrations/20251107172421_AddUserAuthentication.Designer.cs b/Api/Migrations/20251107172421_AddUserAuthentication.Designer.cs new file mode 100644 index 0000000..97f4e70 --- /dev/null +++ b/Api/Migrations/20251107172421_AddUserAuthentication.Designer.cs @@ -0,0 +1,189 @@ +// +using System; +using Api.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251107172421_AddUserAuthentication")] + partial class AddUserAuthentication + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Api.Models.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products"); + + b.HasData( + new + { + Id = 1, + Name = "Sample Product", + Price = 9.99m + }); + }); + + modelBuilder.Entity("Api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("LastName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Api.Models.UserOAuthProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderEmail") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProviderName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ProviderId") + .IsUnique(); + + b.ToTable("UserOAuthProviders"); + }); + + modelBuilder.Entity("WebMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("Subject") + .HasColumnType("nvarchar(max)"); + + b.Property("Surname") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WebMessages"); + }); + + modelBuilder.Entity("Api.Models.UserOAuthProvider", b => + { + b.HasOne("Api.Models.User", "User") + .WithMany("OAuthProviders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Api.Models.User", b => + { + b.Navigation("OAuthProviders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Api/Migrations/20251107172421_AddUserAuthentication.cs b/Api/Migrations/20251107172421_AddUserAuthentication.cs new file mode 100644 index 0000000..e02be5e --- /dev/null +++ b/Api/Migrations/20251107172421_AddUserAuthentication.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Api.Migrations +{ + /// + public partial class AddUserAuthentication : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Email = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + FirstName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + LastName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + ProfilePictureUrl = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastLoginAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserOAuthProviders", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + Provider = table.Column(type: "int", nullable: false), + ProviderId = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + ProviderEmail = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + ProviderName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastUsedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserOAuthProviders", x => x.Id); + table.ForeignKey( + name: "FK_UserOAuthProviders_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserOAuthProviders_Provider_ProviderId", + table: "UserOAuthProviders", + columns: new[] { "Provider", "ProviderId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserOAuthProviders_UserId", + table: "UserOAuthProviders", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserOAuthProviders"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Api/Migrations/AppDbContextModelSnapshot.cs b/Api/Migrations/AppDbContextModelSnapshot.cs index f50dbd9..645a03c 100755 --- a/Api/Migrations/AppDbContextModelSnapshot.cs +++ b/Api/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using Api.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -48,6 +49,91 @@ namespace Api.Migrations }); }); + modelBuilder.Entity("Api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("LastName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Api.Models.UserOAuthProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderEmail") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProviderName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ProviderId") + .IsUnique(); + + b.ToTable("UserOAuthProviders"); + }); + modelBuilder.Entity("WebMessage", b => { b.Property("Id") @@ -78,6 +164,22 @@ namespace Api.Migrations b.ToTable("WebMessages"); }); + + modelBuilder.Entity("Api.Models.UserOAuthProvider", b => + { + b.HasOne("Api.Models.User", "User") + .WithMany("OAuthProviders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Api.Models.User", b => + { + b.Navigation("OAuthProviders"); + }); #pragma warning restore 612, 618 } } diff --git a/Api/Models/AppDbContext.cs b/Api/Models/AppDbContext.cs index 5660535..10d81cd 100755 --- a/Api/Models/AppDbContext.cs +++ b/Api/Models/AppDbContext.cs @@ -14,12 +14,40 @@ namespace Api.Models { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Products { get; set; } - public DbSet WebMessages { get; set; } + public DbSet Users { get; set; } + public DbSet UserOAuthProviders { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + + // Configure User entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Email).IsUnique(); + entity.Property(e => e.Email).IsRequired().HasMaxLength(255); + entity.Property(e => e.FirstName).HasMaxLength(255); + entity.Property(e => e.LastName).HasMaxLength(255); + entity.Property(e => e.ProfilePictureUrl).HasMaxLength(500); + }); + + // Configure UserOAuthProvider entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.Provider, e.ProviderId }).IsUnique(); + entity.Property(e => e.ProviderId).IsRequired().HasMaxLength(255); + entity.Property(e => e.ProviderEmail).HasMaxLength(255); + entity.Property(e => e.ProviderName).HasMaxLength(255); + + entity.HasOne(e => e.User) + .WithMany(u => u.OAuthProviders) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + // Seed data modelBuilder.Entity().HasData( new Product { Id = 1, Name = "Sample Product", Price = 9.99M } diff --git a/Api/Models/DTOs/AuthenticationDtos.cs b/Api/Models/DTOs/AuthenticationDtos.cs new file mode 100644 index 0000000..be78ad1 --- /dev/null +++ b/Api/Models/DTOs/AuthenticationDtos.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; + +namespace Api.Models.DTOs +{ + public class AuthenticateRequest + { + [Required] + public string IdToken { get; set; } = string.Empty; + + [Required] + public string Provider { get; set; } = string.Empty; // "Microsoft", "Google", "PocketId" + + /// + /// Optional access token for API calls (e.g., Microsoft Graph) + /// + public string? AccessToken { get; set; } + } + + public class AuthenticateResponse + { + public string AccessToken { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } + public UserProfile User { get; set; } = null!; + public bool IsNewUser { get; set; } + } + + public class UserProfile + { + public int Id { get; set; } + public string Email { get; set; } = string.Empty; + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ProfilePictureUrl { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public List Providers { get; set; } = new List(); + } + + public class JwtSettings + { + public string SecretKey { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int ExpirationMinutes { get; set; } = 60; + } + + public class OAuthProviderSettings + { + public Dictionary Providers { get; set; } = new Dictionary(); + } + + public class ProviderConfig + { + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string? ClientSecret { get; set; } + public List ValidAudiences { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Api/Models/User.cs b/Api/Models/User.cs new file mode 100644 index 0000000..9a5f151 --- /dev/null +++ b/Api/Models/User.cs @@ -0,0 +1,72 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Api.Models +{ + public class User + { + [Key] + public int Id { get; set; } + + [Required] + [StringLength(255)] + public string Email { get; set; } = string.Empty; + + [StringLength(255)] + public string? FirstName { get; set; } + + [StringLength(255)] + public string? LastName { get; set; } + + [StringLength(500)] + public string? ProfilePictureUrl { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? LastLoginAt { get; set; } + + public bool IsActive { get; set; } = true; + + // Navigation property for OAuth providers + public virtual ICollection OAuthProviders { get; set; } = new List(); + } + + public class UserOAuthProvider + { + [Key] + public int Id { get; set; } + + [Required] + public int UserId { get; set; } + + [Required] + public OAuthProvider Provider { get; set; } + + [Required] + [StringLength(255)] + public string ProviderId { get; set; } = string.Empty; + + [StringLength(255)] + public string? ProviderEmail { get; set; } + + [StringLength(255)] + public string? ProviderName { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? LastUsedAt { get; set; } + + // Navigation property + [ForeignKey("UserId")] + public virtual User User { get; set; } = null!; + } + + public enum OAuthProvider + { + Microsoft = 1, + Google = 2, + PocketId = 3 + // Add more providers as needed + } +} \ No newline at end of file diff --git a/Api/Program.cs b/Api/Program.cs index 85cbaa0..e0e8c2b 100755 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -3,6 +3,9 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Web; using Microsoft.Identity.Web.Resource; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using Api.Services; namespace Api { @@ -17,6 +20,15 @@ namespace Api var builder = WebApplication.CreateBuilder(args); // Add services to the container. + + // Configure JWT authentication for custom tokens + var jwtSettings = builder.Configuration.GetSection("Jwt"); + var secretKey = jwtSettings["SecretKey"]; + if (string.IsNullOrEmpty(secretKey)) + { + throw new InvalidOperationException("JWT SecretKey must be configured"); + } + builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -24,14 +36,46 @@ namespace Api }) .AddJwtBearer(options => { - options.Authority = builder.Configuration["Authentication:PocketId:Authority"]; - options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters() + options.TokenValidationParameters = new TokenValidationParameters { - ValidAudiences = builder.Configuration["Authentication:PocketId:ClientId"]?.Split(';').Select(i => i.Trim()).ToArray(), - ValidIssuers = builder.Configuration["Authentication:PocketId:Authority"]?.Split(';').Select(i => i.Trim()).ToArray() + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ValidateIssuer = true, + ValidIssuer = jwtSettings["Issuer"], + ValidateAudience = true, + ValidAudience = jwtSettings["Audience"], + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5) + }; + + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService() + .CreateLogger("JwtAuthentication"); + logger.LogWarning("JWT authentication failed: {Exception}", context.Exception.Message); + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService() + .CreateLogger("JwtAuthentication"); + var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + logger.LogInformation("JWT token validated for user {UserId}", userId); + return Task.CompletedTask; + } }; }); + // Add authorization + builder.Services.AddAuthorization(); + + // Register custom services + builder.Services.AddHttpClient(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddCors(options => { options.AddPolicy("Default", policy => @@ -43,27 +87,52 @@ namespace Api policy .WithOrigins(allowedHostsConfiguration ?? new[] { "*" }) .AllowAnyHeader() - .AllowAnyMethod(); + .AllowAnyMethod() + .AllowCredentials(); // Allow credentials for JWT tokens }); }); builder.Services.AddControllers(); // Add DbContext with SQL Server - // Allow connection string to be set via environment variable (e.g., in Docker) builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(); + builder.Services.AddSwaggerGen(options => + { + options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme + { + Name = "Authorization", + Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = Microsoft.OpenApi.Models.ParameterLocation.Header, + Description = "JWT Authorization header using the Bearer scheme." + }); + + options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement + { + { + new Microsoft.OpenApi.Models.OpenApiSecurityScheme + { + Reference = new Microsoft.OpenApi.Models.OpenApiReference + { + Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + }); var app = builder.Build(); app.UseSwagger(); app.UseSwaggerUI(); - if (!app.Environment.IsDevelopment()) { app.UseHttpsRedirection(); diff --git a/Api/Services/JwtService.cs b/Api/Services/JwtService.cs new file mode 100644 index 0000000..a373488 --- /dev/null +++ b/Api/Services/JwtService.cs @@ -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 _logger; + private readonly SymmetricSecurityKey _key; + + public JwtService(IConfiguration configuration, ILogger logger) + { + _jwtSettings = configuration.GetSection("Jwt").Get() + ?? 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 + { + 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); + } + } + } +} \ No newline at end of file diff --git a/Api/Services/OAuthValidationService.cs b/Api/Services/OAuthValidationService.cs new file mode 100644 index 0000000..48ebd98 --- /dev/null +++ b/Api/Services/OAuthValidationService.cs @@ -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 _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 + } +} \ No newline at end of file diff --git a/Api/appsettings.json b/Api/appsettings.json index 67108b3..51caf69 100755 --- a/Api/appsettings.json +++ b/Api/appsettings.json @@ -1,4 +1,29 @@ { + "Jwt": { + "SecretKey": "your-very-secure-secret-key-that-should-be-at-least-32-characters-long", + "Issuer": "https://api.yourapp.com", + "Audience": "https://yourapp.com", + "ExpirationMinutes": 60 + }, + "OAuth": { + "Providers": { + "microsoft": { + "Authority": "https://login.microsoftonline.com/4a0d328f-1f94-4920-b67e-4275737d02a5/v2.0", + "ClientId": "eb03f08b-280a-46c7-9700-b012caa46000", + "ValidAudiences": ["eb03f08b-280a-46c7-9700-b012caa46000"] + }, + "google": { + "Authority": "https://accounts.google.com", + "ClientId": "1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com", + "ValidAudiences": ["1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com"] + }, + "pocketid": { + "Authority": "https://identity.lesko.me", + "ClientId": "21131567-fea1-42a2-8907-21abd874eff8", + "ValidAudiences": ["21131567-fea1-42a2-8907-21abd874eff8"] + } + } + }, "Authentication": { "PocketId": { "Authority": "https://identity.lesko.me", @@ -16,5 +41,4 @@ }, "AllowedHosts": "localhost", "CorsOrigins": "https://localhost:5001,http://localhost:4200,http://localhost:5000" - } \ No newline at end of file diff --git a/Web/public/config.json b/Web/public/config.json index 411e034..4aa6a51 100755 --- a/Web/public/config.json +++ b/Web/public/config.json @@ -1,3 +1,18 @@ { - "apiEndpoint": "http://localhost:5000" + "apiEndpoint": "http://localhost:5000", + "oauthProviders": { + "microsoft": { + "clientId": "eb03f08b-280a-46c7-9700-b012caa46000", + "issuer": "https://login.microsoftonline.com/4a0d328f-1f94-4920-b67e-4275737d02a5/v2.0" + }, + "google": { + "clientId": "1000025801082-09qojecdodogc3j8g32d6case1chtb25.apps.googleusercontent.com", + "issuer": "https://accounts.google.com", + "dummyClientSecret": "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" + }, + "pocketid": { + "clientId": "21131567-fea1-42a2-8907-21abd874eff8", + "issuer": "https://identity.lesko.me" + } + } } \ No newline at end of file diff --git a/Web/public/images/svgs/microsoft-icon.svg b/Web/public/images/svgs/microsoft-icon.svg new file mode 100644 index 0000000..71354d9 --- /dev/null +++ b/Web/public/images/svgs/microsoft-icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Web/src/app/app.config.ts b/Web/src/app/app.config.ts index 931e6ff..6223d25 100755 --- a/Web/src/app/app.config.ts +++ b/Web/src/app/app.config.ts @@ -41,9 +41,10 @@ import { adapterFactory } from 'angular-calendar/date-adapters/date-fns'; // code view import { provideHighlightOptions } from 'ngx-highlightjs'; import 'highlight.js/styles/atom-one-dark.min.css'; -import { DefaultOAuthInterceptor, OAuthModule, provideOAuthClient } from 'angular-oauth2-oidc'; +import { OAuthModule, provideOAuthClient } from 'angular-oauth2-oidc'; import { AppConfigService } from './services/config.service'; import { ApiEndpointInterceptor } from './services/http.interceptor'; +import { AuthInterceptor } from './services/auth.interceptor'; export function HttpLoaderFactory(http: HttpClient): any { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); @@ -74,7 +75,7 @@ export const appConfig: ApplicationConfig = { provideAppInitializer(() => inject(AppConfigService).loadConfig()), { provide: HTTP_INTERCEPTORS, - useClass: DefaultOAuthInterceptor, + useClass: AuthInterceptor, multi: true, }, { @@ -84,12 +85,7 @@ export const appConfig: ApplicationConfig = { }, provideHttpClient(withInterceptorsFromDi()), - provideOAuthClient({ - resourceServer: { - allowedUrls: ['http://localhost:5000', 'https://localhost:4200', 'https://centrum.lesko.me', 'https://beta.e-dias.sk/'], - sendAccessToken: true, - }, - }), + provideOAuthClient(), // provideClientHydration(), provideAnimationsAsync(), diff --git a/Web/src/app/app.route.guard.ts b/Web/src/app/app.route.guard.ts index 41e594b..482137f 100755 --- a/Web/src/app/app.route.guard.ts +++ b/Web/src/app/app.route.guard.ts @@ -3,15 +3,23 @@ import { inject } from '@angular/core'; import { AuthenticationService } from './services/authentication.service'; -export const authGuard: CanActivateFn = (route, state) => { +export const authGuard: CanActivateFn = async (route, state) => { const auth = inject(AuthenticationService); const router = inject(Router); - if (auth.hasValidAccessToken()) - if (auth.profile) + if (auth.hasValidAccessToken()) { + if (auth.profile) { return true; - else - return auth.handleCallback(); + } else { + try { + const user = await auth.handleCallback(); + return user ? true : router.parseUrl('/authentication/login'); + } catch (error) { + console.error('Auth guard callback error:', error); + return router.parseUrl('/authentication/login'); + } + } + } // redirect to the login page (UrlTree) so navigation does not fail silently return router.parseUrl('/authentication/login'); diff --git a/Web/src/app/models/auth.models.ts b/Web/src/app/models/auth.models.ts new file mode 100644 index 0000000..043415d --- /dev/null +++ b/Web/src/app/models/auth.models.ts @@ -0,0 +1,35 @@ +export interface AuthenticateRequest { + idToken: string; + provider: string; + accessToken?: string; // Optional access token for API calls +} + +export interface AuthenticateResponse { + accessToken: string; + expiresAt: string; + user: UserProfile; + isNewUser: boolean; +} + +export interface UserProfile { + id: number; + email: string; + firstName?: string; + lastName?: string; + profilePictureUrl?: string; + createdAt: string; + lastLoginAt?: string; + providers: string[]; + + // Computed properties for template compatibility + name?: string; // Will be computed from firstName + lastName + picture?: string; // Alias for profilePictureUrl + role?: string; // Default user role +} + +export interface OAuthConfig { + provider: 'Microsoft' | 'Google' | 'PocketId'; + issuer: string; + clientId: string; + scope?: string; +} \ No newline at end of file diff --git a/Web/src/app/pages/authentication/callback/callback.component.html b/Web/src/app/pages/authentication/callback/callback.component.html index e69de29..ff916d9 100755 --- a/Web/src/app/pages/authentication/callback/callback.component.html +++ b/Web/src/app/pages/authentication/callback/callback.component.html @@ -0,0 +1,30 @@ +
+
+
+
+ +
+ +

Spracúvame prihlásenie...

+

Prosím počkajte, overujeme vaše údaje.

+
+ + +
+ check_circle +

Prihlásenie úspešné!

+

Vitajte, {{ profile.firstName || profile.email }}!

+

Presmerovávame vás na hlavnú stránku...

+
+ + +
+ error +

Prihlásenie zlyhalo

+

{{ error }}

+

Presmerovávame vás späť na prihlásenie...

+
+
+
+
+
\ No newline at end of file diff --git a/Web/src/app/pages/authentication/callback/callback.component.ts b/Web/src/app/pages/authentication/callback/callback.component.ts index 1aec954..dd580c9 100755 --- a/Web/src/app/pages/authentication/callback/callback.component.ts +++ b/Web/src/app/pages/authentication/callback/callback.component.ts @@ -1,31 +1,52 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { Router, RouterModule } from '@angular/router'; import { CoreService } from '../../../services/core.service'; import { MaterialModule } from '../../../material.module'; import { AuthenticationService } from '../../../services/authentication.service'; import { NgScrollbarModule } from "ngx-scrollbar"; +import { CommonModule } from '@angular/common'; @Component({ selector: 'app-callback', - imports: [RouterModule, MaterialModule, NgScrollbarModule], + imports: [RouterModule, MaterialModule, NgScrollbarModule, CommonModule], templateUrl: './callback.component.html', }) -export class CallbackComponent { +export class CallbackComponent implements OnInit { options: any; profile: any; + loading = true; + error: string | null = null; constructor(private settings: CoreService, private as: AuthenticationService, private router: Router) { this.options = this.settings.getOptions(); + } - // Handle the OAuth2 callback and load user profile - this.as - .handleCallback() - .then(_ => { - console.log('Login successful'); - this.profile = this.as.profile; - this.router.navigate(['/dashboard/main']); - }).catch(err => { - console.error('Error handling callback', err); - }); + async ngOnInit() { + try { + // Handle the OAuth2 callback and authenticate with our API + const user = await this.as.handleCallback(); + + if (user) { + console.log('Login successful', user); + this.profile = user; + this.loading = false; + + // Redirect to dashboard after a short delay + setTimeout(() => { + this.router.navigate(['/dashboard/main']); + }, 1500); + } else { + throw new Error('Authentication failed - no user returned'); + } + } catch (err: any) { + console.error('Error handling callback', err); + this.error = err.message || 'Authentication failed. Please try again.'; + this.loading = false; + + // Redirect to login after error delay + setTimeout(() => { + this.router.navigate(['/authentication/side-login']); + }, 3000); + } } } diff --git a/Web/src/app/pages/authentication/side-login/side-login.component.html b/Web/src/app/pages/authentication/side-login/side-login.component.html index d143aaa..bbc5e3e 100755 --- a/Web/src/app/pages/authentication/side-login/side-login.component.html +++ b/Web/src/app/pages/authentication/side-login/side-login.component.html @@ -17,18 +17,28 @@ Váš uživateľský prístup diff --git a/Web/src/app/pages/authentication/side-login/side-login.component.ts b/Web/src/app/pages/authentication/side-login/side-login.component.ts index 4f489e1..b609581 100755 --- a/Web/src/app/pages/authentication/side-login/side-login.component.ts +++ b/Web/src/app/pages/authentication/side-login/side-login.component.ts @@ -5,31 +5,54 @@ import { Router, RouterModule } from '@angular/router'; import { MaterialModule } from '../../../material.module'; import { BrandingComponent } from '../../../layouts/full/vertical/sidebar/branding.component'; import { AuthenticationService } from '../../../services/authentication.service'; +import { CommonModule } from '@angular/common'; @Component({ selector: 'app-side-login', - imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent], + imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent, CommonModule], templateUrl: './side-login.component.html' }) export class AppSideLoginComponent { options: any; + loading = false; constructor(private settings: CoreService, private router: Router, private readonly as: AuthenticationService) { this.options = this.settings.getOptions(); } - googleLogin() { - this.as.configureAndLogin({ - issuer: 'https://accounts.google.com', - clientId: '1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com', - dummyClientSecret: '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', - }); + async loginWithMicrosoft() { + if (this.loading) return; + + this.loading = true; + try { + await this.as.loginWithMicrosoft(); + } catch (error) { + console.error('Microsoft login failed:', error); + this.loading = false; + } } - pocketLogin() { - this.as.configureAndLogin({ - issuer: 'https://identity.lesko.me', - clientId: '21131567-fea1-42a2-8907-21abd874eff8', - }); + async loginWithGoogle() { + if (this.loading) return; + + this.loading = true; + try { + await this.as.loginWithGoogle(); + } catch (error) { + console.error('Google login failed:', error); + this.loading = false; + } + } + + async loginWithPocketId() { + if (this.loading) return; + + this.loading = true; + try { + await this.as.loginWithPocketId(); + } catch (error) { + console.error('PocketId login failed:', error); + this.loading = false; + } } } diff --git a/Web/src/app/services/auth.interceptor.ts b/Web/src/app/services/auth.interceptor.ts new file mode 100644 index 0000000..6816c2c --- /dev/null +++ b/Web/src/app/services/auth.interceptor.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { AuthenticationService } from './authentication.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + constructor(private authService: AuthenticationService) { } + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + // Skip adding auth header for authentication endpoints + if (req.url.includes('/api/auth/authenticate') || req.url.includes('/assets/')) { + return next.handle(req); + } + + // Get custom access token + const token = this.authService.getCustomAccessToken(); + + if (token) { + // Clone request and add Authorization header + const authReq = req.clone({ + headers: req.headers.set('Authorization', `Bearer ${token}`) + }); + return next.handle(authReq); + } + + return next.handle(req); + } +} \ No newline at end of file diff --git a/Web/src/app/services/authentication.service.ts b/Web/src/app/services/authentication.service.ts index 8bf4c06..c7ec257 100644 --- a/Web/src/app/services/authentication.service.ts +++ b/Web/src/app/services/authentication.service.ts @@ -1,14 +1,21 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs'; +import { ToastrService } from 'ngx-toastr'; +import { AuthenticateRequest, AuthenticateResponse, UserProfile, OAuthConfig } from '../models/auth.models'; +import { AppConfigService } from './config.service'; const CONFIG_KEY = 'oauth_config_v1'; +const TOKEN_KEY = 'custom_access_token'; +const USER_KEY = 'user_profile'; @Injectable({ providedIn: 'root' }) export class AuthenticationService { private config: Partial = { - redirectUri: window.location.origin + '/authentication/callback', + redirectUri: this.getRedirectUri(), scope: 'openid profile email', responseType: 'code', requireHttps: false, @@ -16,15 +23,43 @@ export class AuthenticationService { timeoutFactor: 0.01, }; - public profile: any = null; + private getRedirectUri(): string { + // Use the current origin + callback path + const origin = window.location.origin; + + // For development/testing environments, ensure we use the right callback + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + return `${origin}/authentication/callback`; + } + + // For Gitpod/Codespaces or other cloud IDEs + if (origin.includes('gitpod.io') || origin.includes('github.dev') || origin.includes('codespaces')) { + return `${origin}/authentication/callback`; + } + + // Default fallback + return `${origin}/authentication/callback`; + } - constructor(private oauthService: OAuthService, private router: Router) { } + public profile: UserProfile | null = null; + private userSubject = new BehaviorSubject(null); + public user$ = this.userSubject.asObservable(); + + constructor( + private oauthService: OAuthService, + private router: Router, + private http: HttpClient, + private toastr: ToastrService, + private configService: AppConfigService + ) { + this.loadStoredUser(); + } saveConfig(cfg: Partial) { try { localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); } catch { - // ignore} + // ignore } } @@ -37,6 +72,57 @@ export class AuthenticationService { return null; } } + + private loadStoredUser(): void { + try { + const userJson = localStorage.getItem(USER_KEY); + if (userJson) { + const user = JSON.parse(userJson) as UserProfile; + // Ensure computed properties are present (in case they were stored without them) + const enhancedUser: UserProfile = { + ...user, + name: user.name || (user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.firstName || user.lastName || user.email), + picture: user.picture || user.profilePictureUrl || 'https://via.placeholder.com/150?text=User', + role: user.role || 'Používateľ' + }; + this.profile = enhancedUser; + this.userSubject.next(this.profile); + } + } catch { + // ignore + } + } + + private saveUser(user: UserProfile): void { + try { + // Populate computed properties for template compatibility + const enhancedUser: UserProfile = { + ...user, + name: user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.firstName || user.lastName || user.email, + picture: user.profilePictureUrl || 'https://via.placeholder.com/150?text=User', // Default avatar + role: 'Používateľ' // Default role in Slovak + }; + + localStorage.setItem(USER_KEY, JSON.stringify(enhancedUser)); + this.profile = enhancedUser; + this.userSubject.next(enhancedUser); + } catch { + // ignore + } + } + + private saveCustomToken(token: string): void { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + // ignore + } + } + // Configure the library and persist configuration configure(cfg: Partial) { this.config = { ...this.config, ...cfg }; @@ -61,6 +147,10 @@ export class AuthenticationService { // Start login flow using discovery document + Authorization Code (PKCE) startLogin(cfg?: Partial): Promise { if (cfg) this.configure(cfg); + + console.log('OAuth Config:', this.config); + console.log('Redirect URI:', this.config.redirectUri); + return this.oauthService .loadDiscoveryDocument() .then(() => { @@ -70,40 +160,201 @@ export class AuthenticationService { } // Call this on the callback route to process the redirect and obtain tokens + profile - handleCallback(): Promise { - if (this.restoreConfiguration()) - // Ensure discovery document loaded, then process code flow, then load profile - return this.oauthService - .loadDiscoveryDocumentAndTryLogin() - .then((isLoggedIn: boolean) => { - if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) { - return Promise.reject('No valid token after callback'); - } - return this.loadUserProfile(); - }); - else - return this.router.navigate(['/authentication/login']); + async handleCallback(): Promise { + if (!this.restoreConfiguration()) { + this.router.navigate(['/authentication/login']); + return null; + } + + try { + // Process OAuth callback to get ID token + const isLoggedIn = await this.oauthService.loadDiscoveryDocumentAndTryLogin(); + + if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) { + throw new Error('No valid token after callback'); + } + + // Get the ID token from the OAuth service + const idToken = this.oauthService.getIdToken(); + if (!idToken) { + throw new Error('No ID token received from OAuth provider'); + } + + // Get the access token for API calls (if available) + const accessToken = this.oauthService.getAccessToken(); + + // Determine the provider based on the current OAuth configuration + const provider = this.determineProvider(); + + // Call our API to authenticate and get custom access token + const authResponse = await this.authenticateWithApi(idToken, provider, accessToken); + + // Save the custom access token and user profile + this.saveCustomToken(authResponse.accessToken); + this.saveUser(authResponse.user); + + // Show success message + this.toastr.success( + authResponse.isNewUser ? 'Account created successfully!' : 'Logged in successfully!', + 'Authentication' + ); + + return authResponse.user; + } catch (error) { + console.error('Authentication callback failed:', error); + this.toastr.error('Authentication failed. Please try again.', 'Error'); + this.router.navigate(['/authentication/login']); + return null; + } + } + + private determineProvider(): string { + const config = this.oauthService.issuer; + if (config.includes('login.microsoftonline.com')) { + return 'Microsoft'; + } else if (config.includes('accounts.google.com')) { + return 'Google'; + } else if (config.includes('identity.lesko.me')) { + return 'PocketId'; + } + throw new Error(`Unknown OAuth provider: ${config}`); + } + + private async authenticateWithApi(idToken: string, provider: string, accessToken?: string): Promise { + const request: AuthenticateRequest = { + idToken, + provider, + ...(accessToken && { accessToken }) // Only include accessToken if it exists + }; + + console.log('Authenticating with API:', { + provider, + hasIdToken: !!idToken, + hasAccessToken: !!accessToken + }); + + try { + const response = await firstValueFrom( + this.http.post('/api/auth/authenticate', request) + ); + return response; + } catch (error: any) { + console.error('API authentication failed:', error); + if (error.error?.message) { + throw new Error(error.error.message); + } + throw new Error('Authentication with API failed'); + } + } + + async getCurrentUser(): Promise { + if (!this.hasValidCustomToken()) { + return null; + } + + try { + const response = await firstValueFrom( + this.http.get('/api/auth/me') + ); + this.saveUser(response); + return response; + } catch (error) { + console.error('Failed to get current user:', error); + this.clearAuth(); + return null; + } } loadUserProfile(): Promise { + // This method is kept for backward compatibility return this.oauthService.loadUserProfile() .then(profile => { - this.profile = profile["info"]; - return new Promise((resolve) => resolve(profile["info"])); + // Don't override our custom profile with OAuth profile + return profile; }); } - // Convenience helpers + // OAuth2 provider-specific login methods + loginWithMicrosoft(): Promise { + const providerConfig = this.configService.setting?.oauthProviders?.microsoft; + if (!providerConfig) { + throw new Error('Microsoft OAuth configuration not found'); + } + + const microsoftConfig: Partial = { + issuer: providerConfig.issuer, + clientId: providerConfig.clientId, + scope: 'openid profile email https://graph.microsoft.com/User.Read', + }; + return this.startLogin(microsoftConfig); + } + + loginWithGoogle(): Promise { + const providerConfig = this.configService.setting?.oauthProviders?.google; + if (!providerConfig) { + throw new Error('Google OAuth configuration not found'); + } + + const googleConfig: Partial = { + issuer: providerConfig.issuer, + clientId: providerConfig.clientId, + scope: 'openid profile email', + // Override redirect URI for Google to match what might be registered + redirectUri: `${window.location.origin}/authentication/callback` + }; + + console.log('Google OAuth Config:', googleConfig); + return this.startLogin(googleConfig); + } + + loginWithPocketId(): Promise { + const providerConfig = this.configService.setting?.oauthProviders?.pocketid; + if (!providerConfig) { + throw new Error('PocketId OAuth configuration not found'); + } + + const pocketIdConfig: Partial = { + issuer: providerConfig.issuer, + clientId: providerConfig.clientId, + scope: 'openid profile email', + }; + return this.startLogin(pocketIdConfig); + } + + // Token management + hasValidCustomToken(): boolean { + const token = this.getCustomAccessToken(); + if (!token) return false; + + try { + // Basic JWT expiration check + const payload = JSON.parse(atob(token.split('.')[1])); + const now = Math.floor(Date.now() / 1000); + return payload.exp > now; + } catch { + return false; + } + } + + getCustomAccessToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } + } + + // Keep OAuth methods for backward compatibility get events() { return this.oauthService.events; } hasValidAccessToken(): boolean { - return this.oauthService.hasValidAccessToken(); + return this.hasValidCustomToken(); } getAccessToken(): string { - return this.oauthService.getAccessToken(); + return this.getCustomAccessToken() || ''; } getIdToken(): string { @@ -114,12 +365,44 @@ export class AuthenticationService { return this.oauthService.getIdentityClaims() as object | null; } - logout(destroyLocalSession = false) { - if (destroyLocalSession) { - try { - localStorage.removeItem(CONFIG_KEY); - } catch { } + async logout(destroyLocalSession = true): Promise { + try { + // Call API logout endpoint if we have a valid token + if (this.hasValidCustomToken()) { + await firstValueFrom(this.http.post('/api/auth/logout', {})); + } + } catch (error) { + console.warn('Logout API call failed:', error); } - this.oauthService.logOut(); + + this.clearAuth(); + + if (destroyLocalSession) { + this.oauthService.logOut(); + } + + this.toastr.success('Logged out successfully', 'Authentication'); + this.router.navigate(['/authentication/login']); + } + + private clearAuth(): void { + try { + localStorage.removeItem(CONFIG_KEY); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + } catch { } + + this.profile = null; + this.userSubject.next(null); + } + + // Check if user is authenticated + isAuthenticated(): boolean { + return this.hasValidCustomToken() && this.profile !== null; + } + + // Get current user synchronously + getCurrentUserSync(): UserProfile | null { + return this.profile; } } \ No newline at end of file