feat: Implement OAuth2 authentication with Microsoft, Google, and PocketId

- Added JWT configuration to appsettings.json for secure token handling.
- Updated config.json to include OAuth provider details for Microsoft, Google, and PocketId.
- Added Microsoft icon SVG for UI representation.
- Refactored app.config.ts to use a custom AuthInterceptor for managing access tokens.
- Enhanced auth route guard to handle asynchronous authentication checks.
- Created new auth models for structured request and response handling.
- Developed a callback component to manage user login states and transitions.
- Updated side-login component to support multiple OAuth providers with loading states.
- Implemented authentication service methods for handling OAuth login flows and token management.
- Added error handling and user feedback for authentication processes.
This commit is contained in:
Marek Lesko
2025-11-07 19:23:21 +00:00
parent c14f62849f
commit f34d523413
23 changed files with 2090 additions and 83 deletions

View File

@@ -22,6 +22,9 @@
<PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.10.0" /> <PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.10.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.18" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.18" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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<AuthController> _logger;
public AuthController(
AppDbContext context,
IOAuthValidationService oauthValidationService,
IJwtService jwtService,
ILogger<AuthController> logger)
{
_context = context;
_oauthValidationService = oauthValidationService;
_jwtService = jwtService;
_logger = logger;
}
/// <summary>
/// Authenticates a user with an OAuth ID token and returns a custom access token
/// </summary>
/// <param name="request">Authentication request containing ID token and provider</param>
/// <returns>Custom access token and user information</returns>
[HttpPost("authenticate")]
public async Task<ActionResult<AuthenticateResponse>> 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<OAuthProvider>(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" });
}
}
/// <summary>
/// Gets the current user's profile information
/// </summary>
/// <returns>User profile information</returns>
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<UserProfile>> 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" });
}
}
/// <summary>
/// Revokes the current access token (logout)
/// </summary>
/// <returns>Success message</returns>
[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
}
}
}

View File

@@ -0,0 +1,189 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ProfilePictureUrl")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("datetime2");
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<string>("ProviderEmail")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ProviderName")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ProviderId")
.IsUnique();
b.ToTable("UserOAuthProviders");
});
modelBuilder.Entity("WebMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("Message")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<string>("Subject")
.HasColumnType("nvarchar(max)");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Api.Migrations
{
/// <inheritdoc />
public partial class AddUserAuthentication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Email = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
FirstName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
LastName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
ProfilePictureUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastLoginAt = table.Column<DateTime>(type: "datetime2", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserOAuthProviders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
Provider = table.Column<int>(type: "int", nullable: false),
ProviderId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
ProviderEmail = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
ProviderName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastUsedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserOAuthProviders");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated /> // <auto-generated />
using System;
using Api.Models; using Api.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -48,6 +49,91 @@ namespace Api.Migrations
}); });
}); });
modelBuilder.Entity("Api.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ProfilePictureUrl")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("datetime2");
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<string>("ProviderEmail")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ProviderName")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ProviderId")
.IsUnique();
b.ToTable("UserOAuthProviders");
});
modelBuilder.Entity("WebMessage", b => modelBuilder.Entity("WebMessage", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -78,6 +164,22 @@ namespace Api.Migrations
b.ToTable("WebMessages"); 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 #pragma warning restore 612, 618
} }
} }

View File

@@ -14,12 +14,40 @@ namespace Api.Models
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Product> Products { get; set; } public DbSet<Product> Products { get; set; }
public DbSet<WebMessage> WebMessages { get; set; } public DbSet<WebMessage> WebMessages { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<UserOAuthProvider> UserOAuthProviders { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
// Configure User entity
modelBuilder.Entity<User>(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<UserOAuthProvider>(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 // Seed data
modelBuilder.Entity<Product>().HasData( modelBuilder.Entity<Product>().HasData(
new Product { Id = 1, Name = "Sample Product", Price = 9.99M } new Product { Id = 1, Name = "Sample Product", Price = 9.99M }

View File

@@ -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"
/// <summary>
/// Optional access token for API calls (e.g., Microsoft Graph)
/// </summary>
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<string> Providers { get; set; } = new List<string>();
}
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<string, ProviderConfig> Providers { get; set; } = new Dictionary<string, ProviderConfig>();
}
public class ProviderConfig
{
public string Authority { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string? ClientSecret { get; set; }
public List<string> ValidAudiences { get; set; } = new List<string>();
}
}

72
Api/Models/User.cs Normal file
View File

@@ -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<UserOAuthProvider> OAuthProviders { get; set; } = new List<UserOAuthProvider>();
}
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
}
}

View File

@@ -3,6 +3,9 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Abstractions; using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web; using Microsoft.Identity.Web;
using Microsoft.Identity.Web.Resource; using Microsoft.Identity.Web.Resource;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using Api.Services;
namespace Api namespace Api
{ {
@@ -17,6 +20,15 @@ namespace Api
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // 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 => builder.Services.AddAuthentication(options =>
{ {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -24,14 +36,46 @@ namespace Api
}) })
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.Authority = builder.Configuration["Authentication:PocketId:Authority"]; options.TokenValidationParameters = new TokenValidationParameters
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
{ {
ValidAudiences = builder.Configuration["Authentication:PocketId:ClientId"]?.Split(';').Select(i => i.Trim()).ToArray(), ValidateIssuerSigningKey = true,
ValidIssuers = builder.Configuration["Authentication:PocketId:Authority"]?.Split(';').Select(i => i.Trim()).ToArray() 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<ILoggerFactory>()
.CreateLogger("JwtAuthentication");
logger.LogWarning("JWT authentication failed: {Exception}", context.Exception.Message);
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.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<IOAuthValidationService, OAuthValidationService>();
builder.Services.AddScoped<IJwtService, JwtService>();
builder.Services.AddScoped<IOAuthValidationService, OAuthValidationService>();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("Default", policy => options.AddPolicy("Default", policy =>
@@ -43,27 +87,52 @@ namespace Api
policy policy
.WithOrigins(allowedHostsConfiguration ?? new[] { "*" }) .WithOrigins(allowedHostsConfiguration ?? new[] { "*" })
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod(); .AllowAnyMethod()
.AllowCredentials(); // Allow credentials for JWT tokens
}); });
}); });
builder.Services.AddControllers(); builder.Services.AddControllers();
// Add DbContext with SQL Server // Add DbContext with SQL Server
// Allow connection string to be set via environment variable (e.g., in Docker)
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); 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<string>()
}
});
});
var app = builder.Build(); var app = builder.Build();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseHttpsRedirection(); app.UseHttpsRedirection();

139
Api/Services/JwtService.cs Normal file
View File

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

View File

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

View File

@@ -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": { "Authentication": {
"PocketId": { "PocketId": {
"Authority": "https://identity.lesko.me", "Authority": "https://identity.lesko.me",
@@ -16,5 +41,4 @@
}, },
"AllowedHosts": "localhost", "AllowedHosts": "localhost",
"CorsOrigins": "https://localhost:5001,http://localhost:4200,http://localhost:5000" "CorsOrigins": "https://localhost:5001,http://localhost:4200,http://localhost:5000"
} }

View File

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

View File

@@ -0,0 +1,6 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="11" height="11" fill="#F25022"/>
<rect x="15" y="2" width="11" height="11" fill="#7FBA00"/>
<rect x="2" y="15" width="11" height="11" fill="#00A4EF"/>
<rect x="15" y="15" width="11" height="11" fill="#FFB900"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -41,9 +41,10 @@ import { adapterFactory } from 'angular-calendar/date-adapters/date-fns';
// code view // code view
import { provideHighlightOptions } from 'ngx-highlightjs'; import { provideHighlightOptions } from 'ngx-highlightjs';
import 'highlight.js/styles/atom-one-dark.min.css'; 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 { AppConfigService } from './services/config.service';
import { ApiEndpointInterceptor } from './services/http.interceptor'; import { ApiEndpointInterceptor } from './services/http.interceptor';
import { AuthInterceptor } from './services/auth.interceptor';
export function HttpLoaderFactory(http: HttpClient): any { export function HttpLoaderFactory(http: HttpClient): any {
return new TranslateHttpLoader(http, './assets/i18n/', '.json'); return new TranslateHttpLoader(http, './assets/i18n/', '.json');
@@ -74,7 +75,7 @@ export const appConfig: ApplicationConfig = {
provideAppInitializer(() => inject(AppConfigService).loadConfig()), provideAppInitializer(() => inject(AppConfigService).loadConfig()),
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
useClass: DefaultOAuthInterceptor, useClass: AuthInterceptor,
multi: true, multi: true,
}, },
{ {
@@ -84,12 +85,7 @@ export const appConfig: ApplicationConfig = {
}, },
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideOAuthClient({ provideOAuthClient(),
resourceServer: {
allowedUrls: ['http://localhost:5000', 'https://localhost:4200', 'https://centrum.lesko.me', 'https://beta.e-dias.sk/'],
sendAccessToken: true,
},
}),
// provideClientHydration(), // provideClientHydration(),
provideAnimationsAsync(), provideAnimationsAsync(),

View File

@@ -3,15 +3,23 @@ import { inject } from '@angular/core';
import { AuthenticationService } from './services/authentication.service'; import { AuthenticationService } from './services/authentication.service';
export const authGuard: CanActivateFn = (route, state) => { export const authGuard: CanActivateFn = async (route, state) => {
const auth = inject(AuthenticationService); const auth = inject(AuthenticationService);
const router = inject(Router); const router = inject(Router);
if (auth.hasValidAccessToken()) if (auth.hasValidAccessToken()) {
if (auth.profile) if (auth.profile) {
return true; return true;
else } else {
return auth.handleCallback(); 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 // redirect to the login page (UrlTree) so navigation does not fail silently
return router.parseUrl('/authentication/login'); return router.parseUrl('/authentication/login');

View File

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

View File

@@ -0,0 +1,30 @@
<div class="blank-layout-container justify-content-center">
<div class="position-relative row w-100 h-100">
<div class="col-12 d-flex align-items-center justify-content-center h-100">
<div class="text-center">
<!-- Loading State -->
<div *ngIf="loading && !error" class="d-flex flex-column align-items-center">
<mat-spinner diameter="48" class="m-b-16"></mat-spinner>
<h4 class="f-w-500 f-s-20 m-b-8">Spracúvame prihlásenie...</h4>
<p class="f-s-14 text-muted">Prosím počkajte, overujeme vaše údaje.</p>
</div>
<!-- Success State -->
<div *ngIf="!loading && !error && profile" class="d-flex flex-column align-items-center">
<mat-icon class="text-success f-s-48 m-b-16">check_circle</mat-icon>
<h4 class="f-w-500 f-s-20 m-b-8">Prihlásenie úspešné!</h4>
<p class="f-s-14 text-muted m-b-16">Vitajte, {{ profile.firstName || profile.email }}!</p>
<p class="f-s-12 text-muted">Presmerovávame vás na hlavnú stránku...</p>
</div>
<!-- Error State -->
<div *ngIf="error" class="d-flex flex-column align-items-center">
<mat-icon class="text-danger f-s-48 m-b-16">error</mat-icon>
<h4 class="f-w-500 f-s-20 m-b-8">Prihlásenie zlyhalo</h4>
<p class="f-s-14 text-muted m-b-16">{{ error }}</p>
<p class="f-s-12 text-muted">Presmerovávame vás späť na prihlásenie...</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,31 +1,52 @@
import { Component } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { CoreService } from '../../../services/core.service'; import { CoreService } from '../../../services/core.service';
import { MaterialModule } from '../../../material.module'; import { MaterialModule } from '../../../material.module';
import { AuthenticationService } from '../../../services/authentication.service'; import { AuthenticationService } from '../../../services/authentication.service';
import { NgScrollbarModule } from "ngx-scrollbar"; import { NgScrollbarModule } from "ngx-scrollbar";
import { CommonModule } from '@angular/common';
@Component({ @Component({
selector: 'app-callback', selector: 'app-callback',
imports: [RouterModule, MaterialModule, NgScrollbarModule], imports: [RouterModule, MaterialModule, NgScrollbarModule, CommonModule],
templateUrl: './callback.component.html', templateUrl: './callback.component.html',
}) })
export class CallbackComponent { export class CallbackComponent implements OnInit {
options: any; options: any;
profile: any; profile: any;
loading = true;
error: string | null = null;
constructor(private settings: CoreService, private as: AuthenticationService, private router: Router) { constructor(private settings: CoreService, private as: AuthenticationService, private router: Router) {
this.options = this.settings.getOptions(); this.options = this.settings.getOptions();
}
// Handle the OAuth2 callback and load user profile async ngOnInit() {
this.as try {
.handleCallback() // Handle the OAuth2 callback and authenticate with our API
.then(_ => { const user = await this.as.handleCallback();
console.log('Login successful');
this.profile = this.as.profile; if (user) {
this.router.navigate(['/dashboard/main']); console.log('Login successful', user);
}).catch(err => { this.profile = user;
console.error('Error handling callback', err); 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);
}
} }
} }

View File

@@ -17,18 +17,28 @@
<span class="f-s-14 d-block f-s-14 m-t-8">Váš uživateľský prístup</span> <span class="f-s-14 d-block f-s-14 m-t-8">Váš uživateľský prístup</span>
<div class="row m-t-24 align-items-center"> <div class="row m-t-24 align-items-center">
<a mat-stroked-button class="w-100" (click)="googleLogin()"> <a mat-stroked-button class="w-100" (click)="loginWithMicrosoft()" [disabled]="loading">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<img src="/assets/images/svgs/google-icon.svg" alt="google" width="16" class="m-r-8" /> <img src="/assets/images/svgs/microsoft-icon.svg" alt="microsoft" width="16" class="m-r-8" />
Prihlásiť sa pomocou Google <span *ngIf="!loading">Prihlásiť sa pomocou Microsoft</span>
<span *ngIf="loading">Prihlasovanie...</span>
</div> </div>
</a> </a>
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="pocketLogin()"> <a mat-stroked-button class="w-100" style="margin-top:12px" (click)="loginWithGoogle()" [disabled]="loading">
<div class="d-flex align-items-center">
<img src="/assets/images/svgs/google-icon.svg" alt="google" width="16" class="m-r-8" />
<span *ngIf="!loading">Prihlásiť sa pomocou Google</span>
<span *ngIf="loading">Prihlasovanie...</span>
</div>
</a>
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="loginWithPocketId()" [disabled]="loading">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pocket-id-light.svg" <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pocket-id-light.svg"
alt="google" width="16" class="m-r-8" /> alt="pocketid" width="16" class="m-r-8" />
Prihlásiť sa pomocou PocketId <span *ngIf="!loading">Prihlásiť sa pomocou PocketId</span>
<span *ngIf="loading">Prihlasovanie...</span>
</div> </div>
</a> </a>
</div> </div>

View File

@@ -5,31 +5,54 @@ import { Router, RouterModule } from '@angular/router';
import { MaterialModule } from '../../../material.module'; import { MaterialModule } from '../../../material.module';
import { BrandingComponent } from '../../../layouts/full/vertical/sidebar/branding.component'; import { BrandingComponent } from '../../../layouts/full/vertical/sidebar/branding.component';
import { AuthenticationService } from '../../../services/authentication.service'; import { AuthenticationService } from '../../../services/authentication.service';
import { CommonModule } from '@angular/common';
@Component({ @Component({
selector: 'app-side-login', selector: 'app-side-login',
imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent], imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent, CommonModule],
templateUrl: './side-login.component.html' templateUrl: './side-login.component.html'
}) })
export class AppSideLoginComponent { export class AppSideLoginComponent {
options: any; options: any;
loading = false;
constructor(private settings: CoreService, private router: Router, private readonly as: AuthenticationService) { constructor(private settings: CoreService, private router: Router, private readonly as: AuthenticationService) {
this.options = this.settings.getOptions(); this.options = this.settings.getOptions();
} }
googleLogin() { async loginWithMicrosoft() {
this.as.configureAndLogin({ if (this.loading) return;
issuer: 'https://accounts.google.com',
clientId: '1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com', this.loading = true;
dummyClientSecret: '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', try {
}); await this.as.loginWithMicrosoft();
} catch (error) {
console.error('Microsoft login failed:', error);
this.loading = false;
}
} }
pocketLogin() { async loginWithGoogle() {
this.as.configureAndLogin({ if (this.loading) return;
issuer: 'https://identity.lesko.me',
clientId: '21131567-fea1-42a2-8907-21abd874eff8', 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;
}
} }
} }

View File

@@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 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);
}
}

View File

@@ -1,14 +1,21 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; 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 CONFIG_KEY = 'oauth_config_v1';
const TOKEN_KEY = 'custom_access_token';
const USER_KEY = 'user_profile';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthenticationService { export class AuthenticationService {
private config: Partial<AuthConfig> = { private config: Partial<AuthConfig> = {
redirectUri: window.location.origin + '/authentication/callback', redirectUri: this.getRedirectUri(),
scope: 'openid profile email', scope: 'openid profile email',
responseType: 'code', responseType: 'code',
requireHttps: false, requireHttps: false,
@@ -16,15 +23,43 @@ export class AuthenticationService {
timeoutFactor: 0.01, 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<UserProfile | null>(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<AuthConfig>) { saveConfig(cfg: Partial<AuthConfig>) {
try { try {
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
} catch { } catch {
// ignore} // ignore
} }
} }
@@ -37,6 +72,57 @@ export class AuthenticationService {
return null; 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 the library and persist configuration
configure(cfg: Partial<AuthConfig>) { configure(cfg: Partial<AuthConfig>) {
this.config = { ...this.config, ...cfg }; this.config = { ...this.config, ...cfg };
@@ -61,6 +147,10 @@ export class AuthenticationService {
// Start login flow using discovery document + Authorization Code (PKCE) // Start login flow using discovery document + Authorization Code (PKCE)
startLogin(cfg?: Partial<AuthConfig>): Promise<void> { startLogin(cfg?: Partial<AuthConfig>): Promise<void> {
if (cfg) this.configure(cfg); if (cfg) this.configure(cfg);
console.log('OAuth Config:', this.config);
console.log('Redirect URI:', this.config.redirectUri);
return this.oauthService return this.oauthService
.loadDiscoveryDocument() .loadDiscoveryDocument()
.then(() => { .then(() => {
@@ -70,40 +160,201 @@ export class AuthenticationService {
} }
// Call this on the callback route to process the redirect and obtain tokens + profile // Call this on the callback route to process the redirect and obtain tokens + profile
handleCallback(): Promise<any> { async handleCallback(): Promise<UserProfile | null> {
if (this.restoreConfiguration()) if (!this.restoreConfiguration()) {
// Ensure discovery document loaded, then process code flow, then load profile this.router.navigate(['/authentication/login']);
return this.oauthService return null;
.loadDiscoveryDocumentAndTryLogin() }
.then((isLoggedIn: boolean) => {
if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) { try {
return Promise.reject('No valid token after callback'); // Process OAuth callback to get ID token
} const isLoggedIn = await this.oauthService.loadDiscoveryDocumentAndTryLogin();
return this.loadUserProfile();
}); if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) {
else throw new Error('No valid token after callback');
return this.router.navigate(['/authentication/login']); }
// 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<AuthenticateResponse> {
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<AuthenticateResponse>('/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<UserProfile | null> {
if (!this.hasValidCustomToken()) {
return null;
}
try {
const response = await firstValueFrom(
this.http.get<UserProfile>('/api/auth/me')
);
this.saveUser(response);
return response;
} catch (error) {
console.error('Failed to get current user:', error);
this.clearAuth();
return null;
}
} }
loadUserProfile(): Promise<any> { loadUserProfile(): Promise<any> {
// This method is kept for backward compatibility
return this.oauthService.loadUserProfile() return this.oauthService.loadUserProfile()
.then(profile => { .then(profile => {
this.profile = profile["info"]; // Don't override our custom profile with OAuth profile
return new Promise((resolve) => resolve(profile["info"])); return profile;
}); });
} }
// Convenience helpers // OAuth2 provider-specific login methods
loginWithMicrosoft(): Promise<void> {
const providerConfig = this.configService.setting?.oauthProviders?.microsoft;
if (!providerConfig) {
throw new Error('Microsoft OAuth configuration not found');
}
const microsoftConfig: Partial<AuthConfig> = {
issuer: providerConfig.issuer,
clientId: providerConfig.clientId,
scope: 'openid profile email https://graph.microsoft.com/User.Read',
};
return this.startLogin(microsoftConfig);
}
loginWithGoogle(): Promise<void> {
const providerConfig = this.configService.setting?.oauthProviders?.google;
if (!providerConfig) {
throw new Error('Google OAuth configuration not found');
}
const googleConfig: Partial<AuthConfig> = {
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<void> {
const providerConfig = this.configService.setting?.oauthProviders?.pocketid;
if (!providerConfig) {
throw new Error('PocketId OAuth configuration not found');
}
const pocketIdConfig: Partial<AuthConfig> = {
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() { get events() {
return this.oauthService.events; return this.oauthService.events;
} }
hasValidAccessToken(): boolean { hasValidAccessToken(): boolean {
return this.oauthService.hasValidAccessToken(); return this.hasValidCustomToken();
} }
getAccessToken(): string { getAccessToken(): string {
return this.oauthService.getAccessToken(); return this.getCustomAccessToken() || '';
} }
getIdToken(): string { getIdToken(): string {
@@ -114,12 +365,44 @@ export class AuthenticationService {
return this.oauthService.getIdentityClaims() as object | null; return this.oauthService.getIdentityClaims() as object | null;
} }
logout(destroyLocalSession = false) { async logout(destroyLocalSession = true): Promise<void> {
if (destroyLocalSession) { try {
try { // Call API logout endpoint if we have a valid token
localStorage.removeItem(CONFIG_KEY); if (this.hasValidCustomToken()) {
} catch { } 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;
} }
} }