From da1fb137d43854db0cab5b9da1a492f50b640432 Mon Sep 17 00:00:00 2001 From: Ilia Date: Fri, 20 Feb 2026 21:57:52 -0600 Subject: [PATCH 01/37] feat: add jwt auth flow and enable test1 deploy --- .github/workflows/deploy.yml | 30 +++- backend/.env.example | 7 +- backend/docker-compose.yml | 12 +- backend/src/Api/Api.csproj | 3 + .../Api/Auth/AuthenticatedUserExtensions.cs | 18 +++ backend/src/Api/Auth/JwtTokenService.cs | 53 +++++++ backend/src/Api/Endpoints/AgUiEndpoints.cs | 19 ++- backend/src/Api/Endpoints/AgentEndpoints.cs | 89 ++++++++---- backend/src/Api/Endpoints/AuthEndpoints.cs | 131 ++++++++++++++++++ backend/src/Api/Endpoints/ChannelEndpoints.cs | 54 ++++++-- .../Dtos/Agents/CreateAgentBodyDto.cs | 8 +- .../Endpoints/Dtos/Auth/AuthResponseDto.cs | 6 + .../Api/Endpoints/Dtos/Auth/AuthUserDto.cs | 3 + .../Endpoints/Dtos/Auth/LoginRequestDto.cs | 16 +++ .../Endpoints/Dtos/Auth/RegisterRequestDto.cs | 19 +++ .../Dtos/Channels/CreateChannelBodyDto.cs | 2 - .../Dtos/Providers/CreateProviderBodyDto.cs | 4 - .../Endpoints/Dtos/Users/UserPresenceDto.cs | 9 ++ .../src/Api/Endpoints/ProviderEndpoints.cs | 58 +++++--- backend/src/Api/Endpoints/UsersEndpoints.cs | 58 ++++++++ .../Extensions/ServiceCollectionExtensions.cs | 57 ++++++++ backend/src/Api/Program.cs | 18 +-- backend/src/Api/Settings/JwtSettings.cs | 9 ++ backend/src/Api/appsettings.json | 13 ++ backend/src/Domain/Users/User.cs | 51 +++++++ .../Infrastructure.Db/AzureOpsCrewContext.cs | 4 + .../Sqlite/UserEntityTypeConfiguration.cs | 46 ++++++ .../Migrations/M007_AddAppUserTable.cs | 31 +++++ frontend/.env.example | 1 + frontend/app/api/agents/[id]/route.ts | 14 +- frontend/app/api/agents/create/route.ts | 25 ++-- frontend/app/api/agents/route.ts | 14 +- frontend/app/api/auth/login/route.ts | 49 +++++++ frontend/app/api/auth/logout/route.ts | 14 ++ frontend/app/api/auth/me/route.ts | 30 ++++ frontend/app/api/auth/register/route.ts | 49 +++++++ .../app/api/channel-agui/[channelId]/route.ts | 42 ++---- .../app/api/channels/[id]/add-agent/route.ts | 16 +-- .../api/channels/[id]/remove-agent/route.ts | 16 +-- frontend/app/api/channels/[id]/route.ts | 9 +- frontend/app/api/channels/create/route.ts | 52 +++---- frontend/app/api/channels/route.ts | 14 +- .../app/api/copilotkit/[agentId]/route.ts | 14 +- frontend/app/api/copilotkit/route.ts | 23 ++- .../app/api/providers/[id]/models/route.ts | 14 +- frontend/app/api/providers/[id]/route.ts | 8 +- frontend/app/api/providers/[id]/test/route.ts | 8 +- frontend/app/api/providers/route.ts | 59 ++++---- frontend/app/api/providers/test/route.ts | 16 ++- frontend/app/api/settings/route.ts | 17 +-- frontend/app/api/users/route.ts | 52 +++++++ frontend/app/globals.css | 19 --- frontend/app/login/page.tsx | 103 ++++++++++++++ frontend/app/page.tsx | 63 ++++++++- frontend/app/signup/page.tsx | 129 +++++++++++++++++ frontend/components/channel-area.tsx | 4 + frontend/components/copilotkit-provider.tsx | 13 +- .../components/direct-messages-right-pane.tsx | 29 +--- .../components/direct-messages-sidebar.tsx | 35 +++-- frontend/components/direct-messages-view.tsx | 18 +-- frontend/components/icon-sidebar.tsx | 21 ++- frontend/components/manage-agents-dialog.tsx | 2 +- frontend/components/member-list.tsx | 24 ++-- .../components/settings/settings-view.tsx | 2 +- frontend/lib/humans.ts | 20 +++ frontend/lib/server/auth.ts | 38 +++++ frontend/proxy.ts | 48 +++++++ 67 files changed, 1611 insertions(+), 341 deletions(-) create mode 100644 backend/src/Api/Auth/AuthenticatedUserExtensions.cs create mode 100644 backend/src/Api/Auth/JwtTokenService.cs create mode 100644 backend/src/Api/Endpoints/AuthEndpoints.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/AuthResponseDto.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/AuthUserDto.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/LoginRequestDto.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs create mode 100644 backend/src/Api/Endpoints/UsersEndpoints.cs create mode 100644 backend/src/Api/Settings/JwtSettings.cs create mode 100644 backend/src/Domain/Users/User.cs create mode 100644 backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserEntityTypeConfiguration.cs create mode 100644 backend/src/Infrastructure.Db/Migrations/M007_AddAppUserTable.cs create mode 100644 frontend/app/api/auth/login/route.ts create mode 100644 frontend/app/api/auth/logout/route.ts create mode 100644 frontend/app/api/auth/me/route.ts create mode 100644 frontend/app/api/auth/register/route.ts create mode 100644 frontend/app/api/users/route.ts create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/signup/page.tsx create mode 100644 frontend/lib/humans.ts create mode 100644 frontend/lib/server/auth.ts create mode 100644 frontend/proxy.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 26197dc6..9db83192 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,8 @@ on: type: choice options: - dev + - test + - test1 - prod default: dev deploy: @@ -34,9 +36,25 @@ jobs: - name: Set environment id: env run: | - echo "name=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT - echo "rg=AzureOpsCrew-${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT - echo "🎯 Environment: ${{ github.event.inputs.environment }}" + INPUT_ENV="${{ github.event.inputs.environment }}" + if [ "$INPUT_ENV" = "test" ]; then + ENV_NAME="test1" + else + ENV_NAME="$INPUT_ENV" + fi + + if [ "$ENV_NAME" = "test1" ]; then + RESOURCE_GROUP="AzureOpsCrew-dev" + else + RESOURCE_GROUP="AzureOpsCrew-$ENV_NAME" + fi + + echo "name=$ENV_NAME" >> $GITHUB_OUTPUT + echo "rg=$RESOURCE_GROUP" >> $GITHUB_OUTPUT + + echo "🎯 Environment input: $INPUT_ENV" + echo "🎯 Effective environment: $ENV_NAME" + echo "🏷️ Resource Group: $RESOURCE_GROUP" echo "🚀 Deploy enabled: ${{ github.event.inputs.deploy }}" - name: Azure Login @@ -96,7 +114,11 @@ jobs: az containerapp update \ --name ca-azopscrew-backend-${{ needs.build.outputs.env_name }} \ --resource-group ${{ needs.build.outputs.resource_group }} \ - --image ${{ env.ACR_SERVER }}/backend:${{ needs.build.outputs.image_tag }} + --image ${{ env.ACR_SERVER }}/backend:${{ needs.build.outputs.image_tag }} \ + --set-env-vars \ + Jwt__Issuer=${{ secrets.JWT_ISSUER }} \ + Jwt__Audience=${{ secrets.JWT_AUDIENCE }} \ + Jwt__SigningKey=${{ secrets.JWT_SIGNING_KEY }} echo "✅ Backend deployed" - name: Deploy Frontend diff --git a/backend/.env.example b/backend/.env.example index 4571f4f9..db3d699b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,6 @@ -AZURE_OPENAI_API_KEY= \ No newline at end of file +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_API_ENDPOINT= +JWT_SIGNING_KEY=ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars! +JWT_ISSUER=AzureOpsCrew +JWT_AUDIENCE=AzureOpsCrewFrontend +SEEDING_ENABLED=false diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 4e39f5de..33067d73 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -9,10 +9,16 @@ services: - ASPNETCORE_HTTP_PORTS=80 - ASPNETCORE_URLS=http://*:80 - DatabaseProvider=Sqlite - - Sqlite__DataSource=Data Source=azureopscrew.db + - Sqlite__DataSource=Data Source=/app/data/azureopscrew.db - SqlServer__ConnectionString=Server=sqlserver;Database=AzureOpsCrew;User Id=sa;Password=YourStrong@Password;TrustServerCertificate=True; - - EnableSeeding=true - - Seeding__AzureFoundrySeed__Key=${AZURE_OPENAI_API_KEY} + - Jwt__Issuer=${JWT_ISSUER:-AzureOpsCrew} + - Jwt__Audience=${JWT_AUDIENCE:-AzureOpsCrewFrontend} + - Jwt__SigningKey=${JWT_SIGNING_KEY:-ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars!} + - Seeding__IsEnabled=${SEEDING_ENABLED:-false} + - Seeding__AzureFoundrySeed__ApiEndpoint=${AZURE_OPENAI_API_ENDPOINT:-} + - Seeding__AzureFoundrySeed__Key=${AZURE_OPENAI_API_KEY:-} + volumes: + - ./data:/app/data ports: - "42100:80" restart: on-failure diff --git a/backend/src/Api/Api.csproj b/backend/src/Api/Api.csproj index 3aab2cd0..8c80f10e 100644 --- a/backend/src/Api/Api.csproj +++ b/backend/src/Api/Api.csproj @@ -17,11 +17,14 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/backend/src/Api/Auth/AuthenticatedUserExtensions.cs b/backend/src/Api/Auth/AuthenticatedUserExtensions.cs new file mode 100644 index 00000000..74d57917 --- /dev/null +++ b/backend/src/Api/Auth/AuthenticatedUserExtensions.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; +using System.IdentityModel.Tokens.Jwt; + +namespace AzureOpsCrew.Api.Auth; + +public static class AuthenticatedUserExtensions +{ + public static int GetRequiredUserId(this ClaimsPrincipal user) + { + var id = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value + ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrWhiteSpace(id) || !int.TryParse(id, out var userId)) + throw new InvalidOperationException("Missing or invalid authenticated user id."); + + return userId; + } +} diff --git a/backend/src/Api/Auth/JwtTokenService.cs b/backend/src/Api/Auth/JwtTokenService.cs new file mode 100644 index 00000000..f4f60c08 --- /dev/null +++ b/backend/src/Api/Auth/JwtTokenService.cs @@ -0,0 +1,53 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using AzureOpsCrew.Api.Settings; +using AzureOpsCrew.Domain.Users; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace AzureOpsCrew.Api.Auth; + +public sealed class JwtTokenService +{ + private readonly JwtSettings _settings; + private readonly byte[] _signingKey; + + public JwtTokenService(IOptions settings) + { + _settings = settings.Value; + _signingKey = Encoding.UTF8.GetBytes(_settings.SigningKey); + } + + public AuthTokenResult CreateToken(User user) + { + var now = DateTime.UtcNow; + var expiresAtUtc = now.AddMinutes(_settings.AccessTokenMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Email, user.Email), + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Email, user.Email), + new(ClaimTypes.Name, user.DisplayName), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), + }; + + var securityKey = new SymmetricSecurityKey(_signingKey); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _settings.Issuer, + audience: _settings.Audience, + claims: claims, + notBefore: now, + expires: expiresAtUtc, + signingCredentials: credentials); + + var tokenValue = new JwtSecurityTokenHandler().WriteToken(token); + return new AuthTokenResult(tokenValue, expiresAtUtc); + } +} + +public sealed record AuthTokenResult(string AccessToken, DateTime ExpiresAtUtc); diff --git a/backend/src/Api/Endpoints/AgUiEndpoints.cs b/backend/src/Api/Endpoints/AgUiEndpoints.cs index 177e076c..f2ad0c9e 100644 --- a/backend/src/Api/Endpoints/AgUiEndpoints.cs +++ b/backend/src/Api/Endpoints/AgUiEndpoints.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints.Dtos.AGUI; using AzureOpsCrew.Api.Extensions; using AzureOpsCrew.Domain.ProviderServices; @@ -26,6 +27,7 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) app.MapPost("/api/agents/{id}/agui", async ([FromRoute(Name = "id")] Guid agentId, [FromBody] RunAgentInput? input, IProviderFacadeResolver providerFactory, AzureOpsCrewContext dbContext, HttpContext context, CancellationToken cancellationToken) => { if (input is null) return Results.BadRequest(); + var userId = context.User.GetRequiredUserId(); Log.Information("Received AG-UI event for agent with id {AgentId} with threadId {ThreadId} and runId {RunId}", agentId, input.ThreadId, input.RunId); Log.Information("Input: {Input}", JsonConvert.SerializeObject(input)); @@ -53,7 +55,7 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) }; // Find Agent - var agent = dbContext.Set().SingleOrDefault(a => a.Id == agentId); + var agent = dbContext.Set().SingleOrDefault(a => a.Id == agentId && a.ClientId == userId); if (agent is null) { Log.Warning("Unknown agent with id: {AgentId}", agentId); @@ -62,7 +64,7 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) Log.Information("Found agent {AgentId}", agent.Id); // Find Provider - var provider = dbContext.Set().SingleOrDefault(p => p.Id == agent.ProviderId); + var provider = dbContext.Set().SingleOrDefault(p => p.Id == agent.ProviderId && p.ClientId == userId); if (provider is null) { Log.Warning("Unknown provider with id: {ProviderId} for agent {AgentId}", agent.ProviderId, agent.Id); @@ -94,7 +96,8 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) var sseLogger = context.RequestServices.GetRequiredService>(); return new AGUIServerSentEventsResult(events, sseLogger, jsonSerializerOptions); }) - .WithTags("AG-UI"); + .WithTags("AG-UI") + .RequireAuthorization(); app.MapPost("/api/channels/{id:guid}/agui", async ( [FromRoute(Name = "id")] Guid channelId, @@ -105,6 +108,7 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) CancellationToken cancellationToken) => { if (input is null) return Results.BadRequest(); + var userId = http.User.GetRequiredUserId(); Log.Information("Received AG-UI event for channel with id {ChannelId} with threadId {ThreadId} and runId {RunId}", channelId, input.ThreadId, input.RunId); Log.Information("Input: {Input}", JsonConvert.SerializeObject(input)); @@ -115,7 +119,7 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) var clientTools = input.Tools?.AsAITools().ToList(); // 1) Load channel + participants - var channel = await dbContext.Channels.SingleOrDefaultAsync(c => c.Id == channelId, cancellationToken); + var channel = await dbContext.Channels.SingleOrDefaultAsync(c => c.Id == channelId && c.ClientId == userId, cancellationToken); if (channel is null) return Results.BadRequest($"Unknown channel with id: {channelId}"); @@ -124,7 +128,7 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) return Results.BadRequest($"Channel with id {channelId} has no agents added"); var agents = await dbContext.Agents - .Where(a => agendIds.Contains(a.Id)) + .Where(a => agendIds.Contains(a.Id) && a.ClientId == userId) .ToListAsync(cancellationToken); if (agents.Count != agendIds.Count) @@ -132,7 +136,7 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) var providerIds = agents.Select(a => a.ProviderId).Distinct().ToList(); var providers = await dbContext.Providers - .Where(p => providerIds.Contains(p.Id)) + .Where(p => providerIds.Contains(p.Id) && p.ClientId == userId) .ToListAsync(cancellationToken); if (providers.Count != providerIds.Count) return Results.BadRequest("Some providers was not found."); @@ -200,7 +204,8 @@ public static void MapAllAgUi(this IEndpointRouteBuilder app) sseLogger, jsonSerializerOptions); }) - .WithTags("AG-UI"); + .WithTags("AG-UI") + .RequireAuthorization(); } } diff --git a/backend/src/Api/Endpoints/AgentEndpoints.cs b/backend/src/Api/Endpoints/AgentEndpoints.cs index a88b19af..4e56356a 100644 --- a/backend/src/Api/Endpoints/AgentEndpoints.cs +++ b/backend/src/Api/Endpoints/AgentEndpoints.cs @@ -1,3 +1,4 @@ +using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints.Dtos.Agents; using AzureOpsCrew.Domain.Agents; using AzureOpsCrew.Domain.Channels; @@ -11,15 +12,31 @@ public static class AgentEndpoints public static void MapAgentEndpoints(this IEndpointRouteBuilder routeBuilder) { var group = routeBuilder.MapGroup("/api/agents") - .WithTags("Agents"); - - group.MapPost("/create", async (CreateAgentBodyDto body, AzureOpsCrewContext context, CancellationToken cancellationToken) => + .WithTags("Agents") + .RequireAuthorization(); + + group.MapPost("/create", async ( + CreateAgentBodyDto body, + HttpContext httpContext, + AzureOpsCrewContext context, + CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + + if (body.Info is null) + return Results.BadRequest("Info is required"); + + var providerExists = await context.Providers + .AnyAsync(p => p.Id == body.ProviderId && p.ClientId == userId, cancellationToken); + + if (!providerExists) + return Results.BadRequest("Provider not found for current user."); + var providerAgentId = Guid.NewGuid().ToString("D"); var agent = new Agent( Guid.NewGuid(), - body.ClientId, + userId, body.Info!, body.ProviderId, providerAgentId, @@ -34,42 +51,61 @@ public static void MapAgentEndpoints(this IEndpointRouteBuilder routeBuilder) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); - group.MapGet("", async (int clientId, AzureOpsCrewContext context, CancellationToken cancellationToken) => + group.MapGet("", async ( + HttpContext httpContext, + AzureOpsCrewContext context, + CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var agents = await context.Set() - .Where(a => a.ClientId == clientId) + .Where(a => a.ClientId == userId) .OrderBy(a => a.DateCreated) .ToListAsync(cancellationToken); return Results.Ok(agents); }) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest); + .Produces(StatusCodes.Status200OK); - group.MapGet("/{id}", async (Guid id, AzureOpsCrewContext context, CancellationToken cancellationToken) => + group.MapGet("/{id}", async ( + Guid id, + HttpContext httpContext, + AzureOpsCrewContext context, + CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var found = await context.Set() - .SingleOrDefaultAsync(a => a.Id == id, cancellationToken); + .SingleOrDefaultAsync(a => a.Id == id && a.ClientId == userId, cancellationToken); return found is null ? Results.NotFound() : Results.Ok(found); }) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); - group.MapPut("/{id}", async (Guid id, UpdateAgentBodyDto body, AzureOpsCrewContext context, CancellationToken cancellationToken) => + group.MapPut("/{id}", async ( + Guid id, + UpdateAgentBodyDto body, + HttpContext httpContext, + AzureOpsCrewContext context, + CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var found = await context.Set() - .SingleOrDefaultAsync(a => a.Id == id, cancellationToken); + .SingleOrDefaultAsync(a => a.Id == id && a.ClientId == userId, cancellationToken); if (found is null) - { return Results.NotFound(); - } if (body.Info is null) - { return Results.BadRequest("Info is required"); - } + + var providerExists = await context.Providers + .AnyAsync(p => p.Id == body.ProviderId && p.ClientId == userId, cancellationToken); + + if (!providerExists) + return Results.BadRequest("Provider not found for current user."); found.Update(body.Info, body.ProviderId, body.Color); await context.SaveChangesAsync(cancellationToken); @@ -80,27 +116,32 @@ public static void MapAgentEndpoints(this IEndpointRouteBuilder routeBuilder) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{id}", async (Guid id, AzureOpsCrewContext context, CancellationToken cancellationToken) => + group.MapDelete("/{id}", async ( + Guid id, + HttpContext httpContext, + AzureOpsCrewContext context, + CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var found = await context.Set() - .SingleOrDefaultAsync(a => a.Id == id, cancellationToken); + .SingleOrDefaultAsync(a => a.Id == id && a.ClientId == userId, cancellationToken); if (found is null) - { return Results.NotFound(); - } var agentIdString = found.Id.ToString(); - var allChannels = await context.Set().ToListAsync(cancellationToken); - var channelsWithAgent = allChannels + var userChannels = await context.Set() + .Where(c => c.ClientId == userId) + .ToListAsync(cancellationToken); + + var channelsWithAgent = userChannels .Where(c => c.AgentIds.Contains(agentIdString)) .ToList(); foreach (var channel in channelsWithAgent) - { channel.RemoveAgent(agentIdString); - } context.Set().Remove(found); await context.SaveChangesAsync(cancellationToken); diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 00000000..f6cbfafa --- /dev/null +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,131 @@ +using AzureOpsCrew.Api.Auth; +using AzureOpsCrew.Api.Endpoints.Dtos.Auth; +using AzureOpsCrew.Api.Endpoints.Filters; +using AzureOpsCrew.Domain.Users; +using AzureOpsCrew.Infrastructure.Db; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace AzureOpsCrew.Api.Endpoints; + +public static class AuthEndpoints +{ + public static void MapAuthEndpoints(this IEndpointRouteBuilder routeBuilder) + { + var group = routeBuilder.MapGroup("/api/auth") + .WithTags("Auth"); + + group.MapPost("/register", async ( + RegisterRequestDto body, + AzureOpsCrewContext context, + IPasswordHasher passwordHasher, + JwtTokenService jwtTokenService, + CancellationToken cancellationToken) => + { + var normalizedEmail = NormalizeEmail(body.Email); + + var exists = await context.Users + .AnyAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); + + if (exists) + return Results.Conflict(new { error = "Email is already registered." }); + + var displayName = string.IsNullOrWhiteSpace(body.DisplayName) + ? body.Email.Trim() + : body.DisplayName.Trim(); + + var user = new User( + email: body.Email.Trim(), + normalizedEmail: normalizedEmail, + passwordHash: string.Empty, + displayName: displayName); + + var hash = passwordHasher.HashPassword(user, body.Password); + user.UpdatePasswordHash(hash); + + context.Users.Add(user); + await context.SaveChangesAsync(cancellationToken); + + var token = jwtTokenService.CreateToken(user); + return Results.Ok(ToAuthResponse(user, token)); + }) + .AddEndpointFilter>() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status409Conflict) + .AllowAnonymous(); + + group.MapPost("/login", async ( + LoginRequestDto body, + AzureOpsCrewContext context, + IPasswordHasher passwordHasher, + JwtTokenService jwtTokenService, + CancellationToken cancellationToken) => + { + var normalizedEmail = NormalizeEmail(body.Email); + + var user = await context.Users + .SingleOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); + + if (user is null || !user.IsActive) + return Results.Unauthorized(); + + var passwordResult = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, body.Password); + if (passwordResult == PasswordVerificationResult.Failed) + return Results.Unauthorized(); + + if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) + { + var rehash = passwordHasher.HashPassword(user, body.Password); + user.UpdatePasswordHash(rehash); + } + + user.MarkLogin(); + await context.SaveChangesAsync(cancellationToken); + + var token = jwtTokenService.CreateToken(user); + return Results.Ok(ToAuthResponse(user, token)); + }) + .AddEndpointFilter>() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .AllowAnonymous(); + + group.MapGet("/me", async ( + HttpContext httpContext, + AzureOpsCrewContext context, + CancellationToken cancellationToken) => + { + var userId = httpContext.User.GetRequiredUserId(); + + var user = await context.Users + .SingleOrDefaultAsync(u => u.Id == userId && u.IsActive, cancellationToken); + + if (user is null) + return Results.Unauthorized(); + + var now = DateTime.UtcNow; + if (!user.LastLoginAt.HasValue || now - user.LastLoginAt.Value >= TimeSpan.FromMinutes(1)) + { + user.MarkLogin(); + await context.SaveChangesAsync(cancellationToken); + } + + return Results.Ok(new AuthUserDto(user.Id, user.Email, user.DisplayName)); + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .RequireAuthorization(); + } + + private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant(); + + private static AuthResponseDto ToAuthResponse(User user, AuthTokenResult token) + { + return new AuthResponseDto( + token.AccessToken, + token.ExpiresAtUtc, + new AuthUserDto(user.Id, user.Email, user.DisplayName)); + } +} diff --git a/backend/src/Api/Endpoints/ChannelEndpoints.cs b/backend/src/Api/Endpoints/ChannelEndpoints.cs index b5b98c59..b4205d03 100644 --- a/backend/src/Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/Api/Endpoints/ChannelEndpoints.cs @@ -1,3 +1,4 @@ +using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints.Dtos.Channels; using AzureOpsCrew.Domain.Agents; using AzureOpsCrew.Domain.Channels; @@ -11,25 +12,28 @@ public static class ChannelEndpoints public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) { var group = routeBuilder.MapGroup("/api/channels") - .WithTags("Channels"); + .WithTags("Channels") + .RequireAuthorization(); group.MapPost("/create", async ( CreateChannelBodyDto body, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); body.AgentIds = body.AgentIds.Distinct().ToArray(); if (body.AgentIds.Length > 0) { var agentsCount = await context.Set() - .CountAsync(a => body.AgentIds.Contains(a.Id) && a.ClientId == body.ClientId, cancellationToken); + .CountAsync(a => body.AgentIds.Contains(a.Id) && a.ClientId == userId, cancellationToken); if (agentsCount != body.AgentIds.Length) - return Results.BadRequest("One or more AgentIds are invalid or do not belong to the client."); + return Results.BadRequest("One or more AgentIds are invalid or do not belong to the current user."); } - var channel = new Channel(Guid.NewGuid(), body.ClientId, body.Name) + var channel = new Channel(Guid.NewGuid(), userId, body.Name) { Description = body.Description, AgentIds = body.AgentIds.Select(a => a.ToString("D")).ToArray() @@ -38,7 +42,6 @@ public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) await context.AddAsync(channel, cancellationToken); await context.SaveChangesAsync(cancellationToken); - // draft output: guid return Results.Created($"/api/channels/{channel.Id}", new { channelId = channel.Id }); }) .Produces(StatusCodes.Status201Created) @@ -47,16 +50,27 @@ public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapPost("/{id}/add-agent", async ( Guid id, AddAgentBodyDto body, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var channel = await context.Set() - .SingleOrDefaultAsync(c => c.Id == id, cancellationToken); + .SingleOrDefaultAsync(c => c.Id == id && c.ClientId == userId, cancellationToken); if (channel is null) return Results.BadRequest($"Unknown channel with id: {id}"); - channel.AddAgent(body.AgentId.ToString("D")); + var agentExists = await context.Set() + .AnyAsync(a => a.Id == body.AgentId && a.ClientId == userId, cancellationToken); + + if (!agentExists) + return Results.BadRequest($"Unknown agent with id: {body.AgentId}"); + + var agentId = body.AgentId.ToString("D"); + if (!channel.AgentIds.Contains(agentId)) + channel.AddAgent(agentId); await context.SaveChangesAsync(cancellationToken); @@ -67,12 +81,15 @@ public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapPost("/{id}/remove-agent", async ( Guid id, - AddAgentBodyDto body, + RemoveAgentBodyDto body, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var channel = await context.Set() - .SingleOrDefaultAsync(c => c.Id == id, cancellationToken); + .SingleOrDefaultAsync(c => c.Id == id && c.ClientId == userId, cancellationToken); if (channel is null) return Results.BadRequest($"Unknown channel with id: {id}"); @@ -87,27 +104,31 @@ public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) .Produces(StatusCodes.Status400BadRequest); group.MapGet("", async ( - int clientId, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var channels = await context.Set() - .Where(c => c.ClientId == clientId) + .Where(c => c.ClientId == userId) .OrderBy(c => c.DateCreated) .ToListAsync(cancellationToken); return Results.Ok(channels); }) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest); + .Produces(StatusCodes.Status200OK); group.MapGet("/{id}", async ( Guid id, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var channel = await context.Set() - .SingleOrDefaultAsync(c => c.Id == id, cancellationToken); + .SingleOrDefaultAsync(c => c.Id == id && c.ClientId == userId, cancellationToken); return channel is null ? Results.NotFound() : Results.Ok(channel); }) @@ -116,11 +137,14 @@ public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapDelete("/{id}", async ( Guid id, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var channel = await context.Set() - .SingleOrDefaultAsync(c => c.Id == id, cancellationToken); + .SingleOrDefaultAsync(c => c.Id == id && c.ClientId == userId, cancellationToken); if (channel is null) return Results.NotFound(); diff --git a/backend/src/Api/Endpoints/Dtos/Agents/CreateAgentBodyDto.cs b/backend/src/Api/Endpoints/Dtos/Agents/CreateAgentBodyDto.cs index 87de4c1d..e79de437 100644 --- a/backend/src/Api/Endpoints/Dtos/Agents/CreateAgentBodyDto.cs +++ b/backend/src/Api/Endpoints/Dtos/Agents/CreateAgentBodyDto.cs @@ -2,12 +2,10 @@ namespace AzureOpsCrew.Api.Endpoints.Dtos.Agents { - public record CreateAgentBodyDto(AgentInfo Info) - { - public int ClientId { get; set; } - +public record CreateAgentBodyDto(AgentInfo Info) +{ public Guid ProviderId { get; set; } public string Color { get; set; } = "#43b581"; - } +} } diff --git a/backend/src/Api/Endpoints/Dtos/Auth/AuthResponseDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/AuthResponseDto.cs new file mode 100644 index 00000000..41bb2d5a --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/AuthResponseDto.cs @@ -0,0 +1,6 @@ +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed record AuthResponseDto( + string AccessToken, + DateTime ExpiresAtUtc, + AuthUserDto User); diff --git a/backend/src/Api/Endpoints/Dtos/Auth/AuthUserDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/AuthUserDto.cs new file mode 100644 index 00000000..f3034f75 --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/AuthUserDto.cs @@ -0,0 +1,3 @@ +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed record AuthUserDto(int Id, string Email, string DisplayName); diff --git a/backend/src/Api/Endpoints/Dtos/Auth/LoginRequestDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/LoginRequestDto.cs new file mode 100644 index 00000000..73202870 --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/LoginRequestDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed class LoginRequestDto +{ + [Required] + [EmailAddress] + [StringLength(320, MinimumLength = 3)] + public string Email { get; set; } = string.Empty; + + [Required] + [MinLength(8)] + [MaxLength(128)] + public string Password { get; set; } = string.Empty; +} diff --git a/backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs new file mode 100644 index 00000000..995ea343 --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed class RegisterRequestDto +{ + [Required] + [EmailAddress] + [StringLength(320, MinimumLength = 3)] + public string Email { get; set; } = string.Empty; + + [Required] + [MinLength(8)] + [MaxLength(128)] + public string Password { get; set; } = string.Empty; + + [StringLength(120, MinimumLength = 2)] + public string? DisplayName { get; set; } +} diff --git a/backend/src/Api/Endpoints/Dtos/Channels/CreateChannelBodyDto.cs b/backend/src/Api/Endpoints/Dtos/Channels/CreateChannelBodyDto.cs index 5fc1c582..5c273015 100644 --- a/backend/src/Api/Endpoints/Dtos/Channels/CreateChannelBodyDto.cs +++ b/backend/src/Api/Endpoints/Dtos/Channels/CreateChannelBodyDto.cs @@ -2,8 +2,6 @@ namespace AzureOpsCrew.Api.Endpoints.Dtos.Channels; public class CreateChannelBodyDto { - public int ClientId { get; set; } - public string Name { get; set; } = string.Empty; public string? Description { get; set; } diff --git a/backend/src/Api/Endpoints/Dtos/Providers/CreateProviderBodyDto.cs b/backend/src/Api/Endpoints/Dtos/Providers/CreateProviderBodyDto.cs index 907068c8..3ec901ba 100644 --- a/backend/src/Api/Endpoints/Dtos/Providers/CreateProviderBodyDto.cs +++ b/backend/src/Api/Endpoints/Dtos/Providers/CreateProviderBodyDto.cs @@ -5,10 +5,6 @@ namespace AzureOpsCrew.Api.Endpoints.Dtos.Providers; public record CreateProviderBodyDto { - [Required] - [Range(1, int.MaxValue, ErrorMessage = "ClientId is required.")] - public int ClientId { get; set; } - [Required(ErrorMessage = "Name is required.")] [StringLength(200, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 200 characters.")] public string Name { get; set; } = string.Empty; diff --git a/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs b/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs new file mode 100644 index 00000000..b15a3b73 --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs @@ -0,0 +1,9 @@ +namespace AzureOpsCrew.Api.Endpoints.Dtos.Users; + +public sealed record UserPresenceDto( + int Id, + string DisplayName, + string Email, + bool IsOnline, + bool IsCurrentUser, + DateTime? LastSeenAtUtc); diff --git a/backend/src/Api/Endpoints/ProviderEndpoints.cs b/backend/src/Api/Endpoints/ProviderEndpoints.cs index e0ac0fdd..23f3b5f7 100644 --- a/backend/src/Api/Endpoints/ProviderEndpoints.cs +++ b/backend/src/Api/Endpoints/ProviderEndpoints.cs @@ -1,3 +1,4 @@ +using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints.Dtos.Providers; using AzureOpsCrew.Api.Endpoints.Filters; using AzureOpsCrew.Domain.Providers; @@ -12,18 +13,22 @@ public static class ProviderEndpoints public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) { var group = routeBuilder.MapGroup("/api/providers") - .WithTags("Providers"); + .WithTags("Providers") + .RequireAuthorization(); // CREATE group.MapPost("/create", async ( CreateProviderBodyDto body, + HttpContext httpContext, AzureOpsCrewContext context, IProviderFacadeResolver providerServiceFactory, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var config = new Provider( Guid.NewGuid(), - body.ClientId, + userId, body.Name, body.ProviderType, body.ApiKey, @@ -51,9 +56,7 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) // Set models count from test result if (testResult.AvailableModels != null) - { config.SetModelsCount(testResult.AvailableModels.Length); - } await context.AddAsync(config, cancellationToken); await context.SaveChangesAsync(cancellationToken); @@ -64,14 +67,16 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); - // LIST (by client) + // LIST (current user) group.MapGet("", async ( - int clientId, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var configs = await context.Set() - .Where(p => p.ClientId == clientId) + .Where(p => p.ClientId == userId) .OrderBy(p => p.DateCreated) .ToListAsync(cancellationToken); @@ -82,11 +87,14 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) // GET by ID group.MapGet("/{id}", async ( Guid id, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var found = await context.Set() - .SingleOrDefaultAsync(p => p.Id == id, cancellationToken); + .SingleOrDefaultAsync(p => p.Id == id && p.ClientId == userId, cancellationToken); return found is null ? Results.NotFound() : Results.Ok(found.ToResponseDto()); }) @@ -97,12 +105,15 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapPut("/{id}", async ( Guid id, UpdateProviderBodyDto body, + HttpContext httpContext, AzureOpsCrewContext context, IProviderFacadeResolver providerServiceFactory, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var found = await context.Set() - .SingleOrDefaultAsync(p => p.Id == id, cancellationToken); + .SingleOrDefaultAsync(p => p.Id == id && p.ClientId == userId, cancellationToken); if (found is null) return Results.NotFound(); @@ -136,9 +147,7 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) // Set models count from test result if (testResult.AvailableModels != null) - { found.SetModelsCount(testResult.AvailableModels.Length); - } await context.SaveChangesAsync(cancellationToken); @@ -152,11 +161,14 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) // DELETE group.MapDelete("/{id}", async ( Guid id, + HttpContext httpContext, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var found = await context.Set() - .SingleOrDefaultAsync(p => p.Id == id, cancellationToken); + .SingleOrDefaultAsync(p => p.Id == id && p.ClientId == userId, cancellationToken); if (found is null) return Results.NotFound(); @@ -172,17 +184,19 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) // TEST CONNECTION (by inline config, for drafts / not-yet-saved providers) — must be before /{id}/test group.MapPost("/test", async ( TestConnectionBodyDto body, + HttpContext httpContext, IProviderFacadeResolver providerServiceFactory, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); Provider config; // If providerId is supplied, fetch from database if (body.ProviderId.HasValue) { var existing = await context.Set() - .SingleOrDefaultAsync(p => p.Id == body.ProviderId.Value, cancellationToken); + .SingleOrDefaultAsync(p => p.Id == body.ProviderId.Value && p.ClientId == userId, cancellationToken); if (existing is null) return Results.NotFound("Provider not found"); @@ -206,7 +220,7 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) { config = new Provider( Guid.Empty, - 0, + userId, body.Name ?? "Test", body.ProviderType, body.ApiKey, @@ -225,7 +239,8 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) result.LatencyMs, result.CheckedAt, result.Quota, - result.AvailableModels?.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase).Select(m => new ModelInfoDto(m.Id, m.Name, m.ContextSize)).ToArray())); + result.AvailableModels?.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase) + .Select(m => new ModelInfoDto(m.Id, m.Name, m.ContextSize)).ToArray())); }) .AddEndpointFilter>() .Produces(StatusCodes.Status200OK) @@ -234,12 +249,15 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) // TEST CONNECTION (by saved provider id) group.MapPost("/{id}/test", async ( Guid id, + HttpContext httpContext, IProviderFacadeResolver providerServiceFactory, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var config = await context.Set() - .SingleOrDefaultAsync(p => p.Id == id, cancellationToken); + .SingleOrDefaultAsync(p => p.Id == id && p.ClientId == userId, cancellationToken); if (config is null) return Results.NotFound(); @@ -254,7 +272,8 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) result.LatencyMs, result.CheckedAt, result.Quota, - result.AvailableModels?.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase).Select(m => new ModelInfoDto(m.Id, m.Name, m.ContextSize)).ToArray())); + result.AvailableModels?.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase) + .Select(m => new ModelInfoDto(m.Id, m.Name, m.ContextSize)).ToArray())); }) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); @@ -262,12 +281,15 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) // LIST MODELS group.MapGet("/{id}/models", async ( Guid id, + HttpContext httpContext, IProviderFacadeResolver providerServiceFactory, AzureOpsCrewContext context, CancellationToken cancellationToken) => { + var userId = httpContext.User.GetRequiredUserId(); + var config = await context.Set() - .SingleOrDefaultAsync(p => p.Id == id, cancellationToken); + .SingleOrDefaultAsync(p => p.Id == id && p.ClientId == userId, cancellationToken); if (config is null) return Results.NotFound(); diff --git a/backend/src/Api/Endpoints/UsersEndpoints.cs b/backend/src/Api/Endpoints/UsersEndpoints.cs new file mode 100644 index 00000000..ba2ece78 --- /dev/null +++ b/backend/src/Api/Endpoints/UsersEndpoints.cs @@ -0,0 +1,58 @@ +using AzureOpsCrew.Api.Auth; +using AzureOpsCrew.Api.Endpoints.Dtos.Users; +using AzureOpsCrew.Infrastructure.Db; +using Microsoft.EntityFrameworkCore; + +namespace AzureOpsCrew.Api.Endpoints; + +public static class UsersEndpoints +{ + private static readonly TimeSpan OnlineWindow = TimeSpan.FromMinutes(5); + private static readonly TimeSpan PresenceWriteThrottle = TimeSpan.FromMinutes(1); + + public static void MapUsersEndpoints(this IEndpointRouteBuilder routeBuilder) + { + var group = routeBuilder.MapGroup("/api/users") + .WithTags("Users") + .RequireAuthorization(); + + group.MapGet("", async ( + HttpContext httpContext, + AzureOpsCrewContext context, + CancellationToken cancellationToken) => + { + var now = DateTime.UtcNow; + var currentUserId = httpContext.User.GetRequiredUserId(); + + // Keep presence reasonably fresh without writing on every request. + var currentUser = await context.Users + .SingleOrDefaultAsync(u => u.Id == currentUserId && u.IsActive, cancellationToken); + + if (currentUser is null) + return Results.Unauthorized(); + + if (!currentUser.LastLoginAt.HasValue || now - currentUser.LastLoginAt.Value >= PresenceWriteThrottle) + { + currentUser.MarkLogin(); + await context.SaveChangesAsync(cancellationToken); + } + + var users = await context.Users + .AsNoTracking() + .Where(u => u.IsActive) + .OrderBy(u => u.DisplayName) + .Select(u => new UserPresenceDto( + u.Id, + u.DisplayName, + u.Email, + u.LastLoginAt.HasValue && now - u.LastLoginAt.Value <= OnlineWindow, + u.Id == currentUserId, + u.LastLoginAt)) + .ToListAsync(cancellationToken); + + return Results.Ok(users); + }) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized); + } +} diff --git a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs index 8f1f309d..35af1214 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,8 @@ +using System.Text; +using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Settings; using AzureOpsCrew.Domain.Providers; +using AzureOpsCrew.Domain.Users; using AzureOpsCrew.Infrastructure.Db; using Microsoft.EntityFrameworkCore; using AzureOpsCrew.Infrastructure.Db.Migrations; @@ -7,6 +10,9 @@ using Serilog; using AzureOpsCrew.Domain.ProviderServices; using AzureOpsCrew.Infrastructure.Ai.ProviderServices; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; namespace AzureOpsCrew.Api.Extensions; @@ -101,4 +107,55 @@ public static void AddProviderFacades(this IServiceCollection services) client.Timeout = TimeSpan.FromSeconds(30); }); } + + public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) + { + var settings = configuration.GetSection("Jwt").Get() ?? new JwtSettings(); + + if (string.IsNullOrWhiteSpace(settings.Issuer)) + throw new InvalidOperationException("Jwt__Issuer is required."); + + if (string.IsNullOrWhiteSpace(settings.Audience)) + throw new InvalidOperationException("Jwt__Audience is required."); + + if (string.IsNullOrWhiteSpace(settings.SigningKey) || settings.SigningKey.Length < 32) + throw new InvalidOperationException("Jwt__SigningKey must be at least 32 characters."); + + if (settings.AccessTokenMinutes <= 0) + throw new InvalidOperationException("Jwt__AccessTokenMinutes must be greater than zero."); + + if (!environment.IsDevelopment() && + settings.SigningKey.Contains("ChangeThisDevelopmentOnly", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("A production JWT signing key must be configured."); + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey)); + + services.Configure(configuration.GetSection("Jwt")); + services.AddOptions(); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.RequireHttpsMetadata = !environment.IsDevelopment(); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = settings.Issuer, + ValidateAudience = true, + ValidAudience = settings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); + + services.AddAuthorization(); + services.AddSingleton(); + services.AddScoped, PasswordHasher>(); + } } diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index d37466a1..98ccd489 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -3,7 +3,6 @@ using AzureOpsCrew.Api.Settings; using AzureOpsCrew.Api.Setup.Seeds; using Microsoft.Extensions.Options; -using Microsoft.OpenApi; using Newtonsoft.Json; using Serilog; @@ -39,21 +38,12 @@ // Enable OpenAPI/Swagger builder.Services.AddOpenApi(); - builder.Services.AddSwaggerGen(options => - { - options.SwaggerDoc("v1", new OpenApiInfo - { - Title = "AzureOpsCrew HTTP Api", - Version = "v1" - }); - - // Sort operations alphabetically by tag and path - options.OrderActionsBy((apiDesc) => $"{apiDesc.RelativePath}"); - }); + builder.Services.AddSwaggerGen(); // Configure settings and database builder.Services.AddDatabase(builder.Configuration); builder.Services.AddProviderFacades(); + builder.Services.AddJwtAuthentication(builder.Configuration, builder.Environment); // Configure AG-UI builder.Services.AddHttpClient(); @@ -98,8 +88,12 @@ } app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); // Map endpoints + app.MapAuthEndpoints(); + app.MapUsersEndpoints(); app.MapTestEndpoints(); app.MapAgentEndpoints(); app.MapChannelEndpoints(); diff --git a/backend/src/Api/Settings/JwtSettings.cs b/backend/src/Api/Settings/JwtSettings.cs new file mode 100644 index 00000000..c848ca82 --- /dev/null +++ b/backend/src/Api/Settings/JwtSettings.cs @@ -0,0 +1,9 @@ +namespace AzureOpsCrew.Api.Settings; + +public sealed class JwtSettings +{ + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string SigningKey { get; set; } = string.Empty; + public int AccessTokenMinutes { get; set; } = 480; +} diff --git a/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index 7c143382..e7fb7ff6 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -17,6 +17,19 @@ "SqlServer": { "ConnectionString": "Server=localhost;Database=AzureOpsCrew;Trusted_Connection=True;TrustServerCertificate=True;" }, + "Jwt": { + "Issuer": "AzureOpsCrew", + "Audience": "AzureOpsCrewFrontend", + "SigningKey": "ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars!", + "AccessTokenMinutes": 480 + }, + "Seeding": { + "IsEnabled": false, + "AzureFoundrySeed": { + "ApiEndpoint": "", + "Key": "" + } + }, "EnableSeeding": "false", "SeedingProviderKey": "YOUR_API_KEY" } diff --git a/backend/src/Domain/Users/User.cs b/backend/src/Domain/Users/User.cs new file mode 100644 index 00000000..ee0a6b3f --- /dev/null +++ b/backend/src/Domain/Users/User.cs @@ -0,0 +1,51 @@ +#pragma warning disable CS8618 + +namespace AzureOpsCrew.Domain.Users; + +public sealed class User +{ + private User() + { + } + + public User(string email, string normalizedEmail, string passwordHash, string displayName) + { + Email = email; + NormalizedEmail = normalizedEmail; + PasswordHash = passwordHash; + DisplayName = displayName; + } + + public int Id { get; private set; } + public string Email { get; private set; } + public string NormalizedEmail { get; private set; } + public string PasswordHash { get; private set; } + public string DisplayName { get; private set; } + public bool IsActive { get; private set; } = true; + public DateTime DateCreated { get; private set; } = DateTime.UtcNow; + public DateTime? DateModified { get; private set; } + public DateTime? LastLoginAt { get; private set; } + + public void UpdateDisplayName(string displayName) + { + DisplayName = displayName; + DateModified = DateTime.UtcNow; + } + + public void UpdatePasswordHash(string passwordHash) + { + PasswordHash = passwordHash; + DateModified = DateTime.UtcNow; + } + + public void MarkLogin() + { + LastLoginAt = DateTime.UtcNow; + } + + public void SetActive(bool isActive) + { + IsActive = isActive; + DateModified = DateTime.UtcNow; + } +} diff --git a/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs b/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs index 3c88c490..d59db2f6 100644 --- a/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs +++ b/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs @@ -1,9 +1,11 @@ using AzureOpsCrew.Domain.Agents; using AzureOpsCrew.Domain.Channels; +using AzureOpsCrew.Domain.Users; using Microsoft.EntityFrameworkCore; using AgentConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.AgentEntityTypeConfiguration; using ChannelConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.ChannelEntityTypeConfiguration; using AiProviderConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.ProviderEntityTypeConfiguration; +using UserConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.UserEntityTypeConfiguration; using AiProvider = AzureOpsCrew.Domain.Providers.Provider; namespace AzureOpsCrew.Infrastructure.Db; @@ -18,11 +20,13 @@ public AzureOpsCrewContext(DbContextOptions options) public DbSet Agents => Set(); public DbSet Channels => Set(); public DbSet Providers => Set(); + public DbSet Users => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new AgentConfig()); modelBuilder.ApplyConfiguration(new ChannelConfig()); modelBuilder.ApplyConfiguration(new AiProviderConfig()); + modelBuilder.ApplyConfiguration(new UserConfig()); } } diff --git a/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserEntityTypeConfiguration.cs b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserEntityTypeConfiguration.cs new file mode 100644 index 00000000..93896ae7 --- /dev/null +++ b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserEntityTypeConfiguration.cs @@ -0,0 +1,46 @@ +using AzureOpsCrew.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite; + +public sealed class UserEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AppUser"); + + builder.HasKey(x => x.Id); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + + builder.Property(x => x.Email) + .IsRequired() + .HasMaxLength(320); + + builder.Property(x => x.NormalizedEmail) + .IsRequired() + .HasMaxLength(320); + + builder.HasIndex(x => x.NormalizedEmail) + .IsUnique(); + + builder.Property(x => x.PasswordHash) + .IsRequired() + .HasMaxLength(512); + + builder.Property(x => x.DisplayName) + .IsRequired() + .HasMaxLength(120); + + builder.Property(x => x.IsActive) + .IsRequired() + .HasDefaultValue(true); + + builder.Property(x => x.DateCreated) + .IsRequired(); + + builder.Property(x => x.DateModified); + builder.Property(x => x.LastLoginAt); + } +} diff --git a/backend/src/Infrastructure.Db/Migrations/M007_AddAppUserTable.cs b/backend/src/Infrastructure.Db/Migrations/M007_AddAppUserTable.cs new file mode 100644 index 00000000..e52b1e6d --- /dev/null +++ b/backend/src/Infrastructure.Db/Migrations/M007_AddAppUserTable.cs @@ -0,0 +1,31 @@ +using FluentMigrator; + +namespace AzureOpsCrew.Infrastructure.Db.Migrations; + +[Migration(2026_02_21_12_00_00, "Add AppUser table")] +public class M007_AddAppUserTable : Migration +{ + public override void Up() + { + Create.Table("AppUser") + .WithColumn("Id").AsInt32().PrimaryKey().Identity() + .WithColumn("Email").AsString(320).NotNullable() + .WithColumn("NormalizedEmail").AsString(320).NotNullable() + .WithColumn("PasswordHash").AsString(512).NotNullable() + .WithColumn("DisplayName").AsString(120).NotNullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("DateCreated").AsDateTime().NotNullable() + .WithColumn("DateModified").AsDateTime().Nullable() + .WithColumn("LastLoginAt").AsDateTime().Nullable(); + + Create.Index("IX_AppUser_NormalizedEmail") + .OnTable("AppUser") + .OnColumn("NormalizedEmail") + .Unique(); + } + + public override void Down() + { + Delete.Table("AppUser"); + } +} diff --git a/frontend/.env.example b/frontend/.env.example index c2d4ce52..b7e9263d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,4 @@ # Backend API endpoint for direct API calls (e.g. agent creation). # Default when unset: http://localhost:5000 +# If backend runs via docker-compose from /backend, use: http://localhost:42100 BACKEND_API_URL= diff --git a/frontend/app/api/agents/[id]/route.ts b/frontend/app/api/agents/[id]/route.ts index 998b0b9a..ab5b550c 100644 --- a/frontend/app/api/agents/[id]/route.ts +++ b/frontend/app/api/agents/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import type { Agent } from "@/lib/agents" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" @@ -24,6 +25,10 @@ export async function PUT( { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params if (!id) { @@ -62,7 +67,7 @@ export async function PUT( const response = await fetch(`${BACKEND_API_URL}/api/agents/${id}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, + headers: buildBackendHeaders(req), body: JSON.stringify(backendBody), }) @@ -96,10 +101,14 @@ export async function PUT( } export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params if (!id) { @@ -111,6 +120,7 @@ export async function DELETE( const response = await fetch(`${BACKEND_API_URL}/api/agents/${id}`, { method: "DELETE", + headers: buildBackendHeaders(req), }) if (!response.ok) { diff --git a/frontend/app/api/agents/create/route.ts b/frontend/app/api/agents/create/route.ts index 1dd4f8bb..60b2c37d 100644 --- a/frontend/app/api/agents/create/route.ts +++ b/frontend/app/api/agents/create/route.ts @@ -1,11 +1,9 @@ import { NextRequest, NextResponse } from "next/server" import type { Agent } from "@/lib/agents" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var -// Defaults to localhost:5000 for local development const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -// Backend response structure interface BackendAgent { id: string providerAgentId: string @@ -24,10 +22,13 @@ interface BackendAgent { export async function POST(req: NextRequest) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const body = await req.json() const { name, model, systemPrompt, color, providerId } = body - // Validate required fields if (!name?.trim()) { return NextResponse.json( { error: "Agent name is required" }, @@ -42,26 +43,19 @@ export async function POST(req: NextRequest) { ) } - // Backend expects CreateAgentBodyDto structure: - // { info: { name, prompt, model }, clientId, providerId, color } const backendBody = { info: { name: name.trim(), prompt: systemPrompt?.trim() || `You are ${name.trim()}, a helpful AI assistant.`, model, }, - clientId: 1, // Default client ID - providerId, // Provider ID from request - color: color || "#43b581", // Default color + providerId, + color: color || "#43b581", } - const createUrl = `${BACKEND_API_URL}/api/agents/create` - - const response = await fetch(createUrl, { + const response = await fetch(`${BACKEND_API_URL}/api/agents/create`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: buildBackendHeaders(req), body: JSON.stringify(backendBody), }) @@ -75,7 +69,6 @@ export async function POST(req: NextRequest) { const backendAgent: BackendAgent = await response.json() - // Transform backend response to frontend Agent interface const frontendAgent: Agent = { id: backendAgent.id, name: backendAgent.info.name, diff --git a/frontend/app/api/agents/route.ts b/frontend/app/api/agents/route.ts index 5786fe67..6a0a3676 100644 --- a/frontend/app/api/agents/route.ts +++ b/frontend/app/api/agents/route.ts @@ -1,10 +1,9 @@ import { NextRequest, NextResponse } from "next/server" import type { Agent } from "@/lib/agents" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -// Backend response structure interface BackendAgent { id: string providerAgentId: string @@ -23,13 +22,13 @@ interface BackendAgent { export async function GET(req: NextRequest) { try { - const agentsUrl = `${BACKEND_API_URL}/api/agents?clientId=1` + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } - const response = await fetch(agentsUrl, { + const response = await fetch(`${BACKEND_API_URL}/api/agents`, { method: "GET", - headers: { - "Content-Type": "application/json", - }, + headers: buildBackendHeaders(req), }) if (!response.ok) { @@ -42,7 +41,6 @@ export async function GET(req: NextRequest) { const backendAgents: BackendAgent[] = await response.json() - // Transform backend agents to frontend Agent interface const frontendAgents: Agent[] = backendAgents.map((backendAgent) => ({ id: backendAgent.id, name: backendAgent.info.name, diff --git a/frontend/app/api/auth/login/route.ts b/frontend/app/api/auth/login/route.ts new file mode 100644 index 00000000..1c57492e --- /dev/null +++ b/frontend/app/api/auth/login/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME, getAuthCookieOptions } from "@/lib/server/auth" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +interface BackendAuthResponse { + accessToken: string + expiresAtUtc: string + user: { + id: number + email: string + displayName: string + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const response = await fetch(`${BACKEND_API_URL}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + return NextResponse.json( + data?.error ? { error: data.error } : { error: "Login failed" }, + { status: response.status } + ) + } + + const authData = data as BackendAuthResponse + if (!authData.accessToken) { + return NextResponse.json({ error: "Missing access token" }, { status: 502 }) + } + + const nextResponse = NextResponse.json({ + expiresAtUtc: authData.expiresAtUtc, + user: authData.user, + }) + nextResponse.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) + + return nextResponse + } catch (error) { + console.error("Error logging in:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts new file mode 100644 index 00000000..686f72fe --- /dev/null +++ b/frontend/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME } from "@/lib/server/auth" + +export async function POST() { + const response = NextResponse.json({ ok: true }) + response.cookies.set(ACCESS_TOKEN_COOKIE_NAME, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + path: "/", + maxAge: 0, + }) + return response +} diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts new file mode 100644 index 00000000..854e3470 --- /dev/null +++ b/frontend/app/api/auth/me/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +export async function GET(req: NextRequest) { + try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const response = await fetch(`${BACKEND_API_URL}/api/auth/me`, { + method: "GET", + headers: buildBackendHeaders(req), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + return NextResponse.json( + data?.error ? { error: data.error } : { error: "Unauthorized" }, + { status: response.status } + ) + } + + return NextResponse.json(data) + } catch (error) { + console.error("Error fetching current user:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/auth/register/route.ts b/frontend/app/api/auth/register/route.ts new file mode 100644 index 00000000..2345a753 --- /dev/null +++ b/frontend/app/api/auth/register/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME, getAuthCookieOptions } from "@/lib/server/auth" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +interface BackendAuthResponse { + accessToken: string + expiresAtUtc: string + user: { + id: number + email: string + displayName: string + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const response = await fetch(`${BACKEND_API_URL}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + return NextResponse.json( + data?.error ? { error: data.error } : { error: "Registration failed" }, + { status: response.status } + ) + } + + const authData = data as BackendAuthResponse + if (!authData.accessToken) { + return NextResponse.json({ error: "Missing access token" }, { status: 502 }) + } + + const nextResponse = NextResponse.json({ + expiresAtUtc: authData.expiresAtUtc, + user: authData.user, + }) + nextResponse.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) + + return nextResponse + } catch (error) { + console.error("Error registering:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/channel-agui/[channelId]/route.ts b/frontend/app/api/channel-agui/[channelId]/route.ts index 4309688b..e5ac9177 100644 --- a/frontend/app/api/channel-agui/[channelId]/route.ts +++ b/frontend/app/api/channel-agui/[channelId]/route.ts @@ -1,8 +1,8 @@ import { NextRequest } from "next/server" import type { AGUIEvent } from "@ag-ui/core" import { EventType } from "@ag-ui/core" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" export const maxDuration = 300 @@ -18,54 +18,42 @@ interface ChatRequest { customModel?: string } -/** - * POST /api/channel-agui/[channelId] - * - * Proxies requests to the backend AGUI endpoint at /api/channels/{channelId}/agui - */ export async function POST( req: NextRequest, { params }: { params: Promise<{ channelId: string }> } ) { - const { channelId } = await params + if (!getAccessToken(req)) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } - // Parse request body + const { channelId } = await params const body: ChatRequest = await req.json() - // Map frontend chat messages to AGUI message format const aguiMessages = body.messages.map((msg) => ({ id: crypto.randomUUID(), role: msg.role, content: msg.content, })) - // Prepare AGUI request payload (RunAgentInput format) const runAgentInput = { threadId: crypto.randomUUID(), runId: crypto.randomUUID(), messages: aguiMessages, state: null, context: [], - // tools could be passed from client in the future tools: undefined, } - // Build backend AGUI URL const backendUrl = `${BACKEND_API_URL}/api/channels/${channelId}/agui` try { - // Forward request to backend const response = await fetch(backendUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - // Forward authorization header if present - ...(req.headers.get("authorization") - ? { Authorization: req.headers.get("authorization")! } - : {}), - }, + headers: buildBackendHeaders(req), body: JSON.stringify(runAgentInput), - // Forward abort signal for cancellation signal: req.signal, }) @@ -78,12 +66,10 @@ export async function POST( ) } - // Check if response is SSE (text/event-stream) const contentType = response.headers.get("content-type") const isSSE = contentType?.includes("text/event-stream") if (!isSSE) { - // If not SSE, just return the response as-is return new Response(response.body, { headers: { "Content-Type": contentType ?? "application/json", @@ -91,7 +77,6 @@ export async function POST( }) } - // Stream SSE events and transform to frontend-compatible format const reader = response.body?.getReader() if (!reader) { return new Response(JSON.stringify({ error: "No response body" }), { @@ -103,7 +88,6 @@ export async function POST( const encoder = new TextEncoder() const decoder = new TextDecoder() - // Create a readable stream for transformed events const stream = new ReadableStream({ async start(controller) { let buffer = "" @@ -126,29 +110,24 @@ export async function POST( try { const event: AGUIEvent = JSON.parse(data) - // Forward TEXT_MESSAGE_START event (includes authorName for agent identification) if (event.type === EventType.TEXT_MESSAGE_START) { controller.enqueue( encoder.encode(`data: ${JSON.stringify(event)}\n\n`) ) } - // Forward TEXT_MESSAGE_CONTENT event as-is if (event.type === EventType.TEXT_MESSAGE_CONTENT) { controller.enqueue( encoder.encode(`data: ${JSON.stringify(event)}\n\n`) ) } - // Forward TEXT_MESSAGE_END event if (event.type === EventType.TEXT_MESSAGE_END) { controller.enqueue( encoder.encode(`data: ${JSON.stringify(event)}\n\n`) ) } - // For now, also forward tool events and other events as-is - // The frontend can be extended to handle these if ( event.type === EventType.TOOL_CALL_START || event.type === EventType.TOOL_CALL_ARGS || @@ -160,7 +139,6 @@ export async function POST( ) } - // Forward run events for monitoring if ( event.type === EventType.RUN_STARTED || event.type === EventType.RUN_FINISHED || @@ -190,7 +168,7 @@ export async function POST( headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-store", - "Connection": "keep-alive", + Connection: "keep-alive", }, }) } catch (error) { diff --git a/frontend/app/api/channels/[id]/add-agent/route.ts b/frontend/app/api/channels/[id]/add-agent/route.ts index ebe5bd2e..91e6d50f 100644 --- a/frontend/app/api/channels/[id]/add-agent/route.ts +++ b/frontend/app/api/channels/[id]/add-agent/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -// Backend DTO structure interface AddAgentBodyDto { agentId: string } @@ -13,11 +12,14 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params const body = await req.json() const { agentId } = body - // Validate required fields if (!agentId) { return NextResponse.json( { error: "Agent ID is required" }, @@ -29,13 +31,9 @@ export async function POST( agentId, } - const addUrl = `${BACKEND_API_URL}/api/channels/${id}/add-agent` - - const response = await fetch(addUrl, { + const response = await fetch(`${BACKEND_API_URL}/api/channels/${id}/add-agent`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: buildBackendHeaders(req), body: JSON.stringify(backendBody), }) diff --git a/frontend/app/api/channels/[id]/remove-agent/route.ts b/frontend/app/api/channels/[id]/remove-agent/route.ts index 33ef6cff..4405526e 100644 --- a/frontend/app/api/channels/[id]/remove-agent/route.ts +++ b/frontend/app/api/channels/[id]/remove-agent/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -// Backend DTO structure interface RemoveAgentBodyDto { agentId: string } @@ -13,11 +12,14 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params const body = await req.json() const { agentId } = body - // Validate required fields if (!agentId) { return NextResponse.json( { error: "Agent ID is required" }, @@ -29,13 +31,9 @@ export async function POST( agentId, } - const removeUrl = `${BACKEND_API_URL}/api/channels/${id}/remove-agent` - - const response = await fetch(removeUrl, { + const response = await fetch(`${BACKEND_API_URL}/api/channels/${id}/remove-agent`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: buildBackendHeaders(req), body: JSON.stringify(backendBody), }) diff --git a/frontend/app/api/channels/[id]/route.ts b/frontend/app/api/channels/[id]/route.ts index 89efc3b7..d696d7cf 100644 --- a/frontend/app/api/channels/[id]/route.ts +++ b/frontend/app/api/channels/[id]/route.ts @@ -1,13 +1,17 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params if (!id) { @@ -19,6 +23,7 @@ export async function DELETE( const response = await fetch(`${BACKEND_API_URL}/api/channels/${id}`, { method: "DELETE", + headers: buildBackendHeaders(req), }) if (!response.ok) { diff --git a/frontend/app/api/channels/create/route.ts b/frontend/app/api/channels/create/route.ts index e7b3f6fa..e881a34a 100644 --- a/frontend/app/api/channels/create/route.ts +++ b/frontend/app/api/channels/create/route.ts @@ -1,18 +1,15 @@ import { NextRequest, NextResponse } from "next/server" import type { Channel } from "@/lib/agents" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -// Backend DTO structure interface CreateChannelBodyDto { - clientId: number name: string description?: string | null agentIds: string[] } -// Backend response structure interface BackendChannel { id: string clientId: number @@ -25,10 +22,13 @@ interface BackendChannel { export async function POST(req: NextRequest) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const body = await req.json() const { name, agentIds } = body - // Validate required fields if (!name?.trim()) { return NextResponse.json( { error: "Channel name is required" }, @@ -37,19 +37,14 @@ export async function POST(req: NextRequest) { } const backendBody: CreateChannelBodyDto = { - clientId: 1, name: name.trim(), description: null, agentIds: agentIds || [], } - const createUrl = `${BACKEND_API_URL}/api/channels/create` - - const response = await fetch(createUrl, { + const response = await fetch(`${BACKEND_API_URL}/api/channels/create`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: buildBackendHeaders(req), body: JSON.stringify(backendBody), }) @@ -62,37 +57,34 @@ export async function POST(req: NextRequest) { } const result = await response.json() - // Backend returns { channelId: "guid" } - - // Fetch the newly created channel to get the full data including dateCreated const channelId = result.channelId - let frontendChannel: Channel if (channelId) { - try { - const channelResponse = await fetch(`${BACKEND_API_URL}/api/channels?clientId=1`) - if (channelResponse.ok) { - const channels: Channel[] = await channelResponse.json() - const newChannel = channels.find((c) => c.id === channelId) - if (newChannel) { - frontendChannel = newChannel - return NextResponse.json(frontendChannel) - } + const channelResponse = await fetch(`${BACKEND_API_URL}/api/channels/${channelId}`, { + method: "GET", + headers: buildBackendHeaders(req), + }) + + if (channelResponse.ok) { + const channel: BackendChannel = await channelResponse.json() + const frontendChannel: Channel = { + id: channel.id, + name: channel.name, + agentIds: channel.agentIds || [], + dateCreated: channel.dateCreated, } - } catch { - // Fall through to creating a channel with current timestamp + return NextResponse.json(frontendChannel) } } - // Fallback: create channel with current timestamp - frontendChannel = { + const fallbackChannel: Channel = { id: channelId || crypto.randomUUID(), name: name.trim(), agentIds: agentIds || [], dateCreated: new Date().toISOString(), } - return NextResponse.json(frontendChannel) + return NextResponse.json(fallbackChannel) } catch (error) { console.error("Error creating channel:", error) return NextResponse.json( diff --git a/frontend/app/api/channels/route.ts b/frontend/app/api/channels/route.ts index 0ba04fb9..104f915c 100644 --- a/frontend/app/api/channels/route.ts +++ b/frontend/app/api/channels/route.ts @@ -1,10 +1,9 @@ import { NextRequest, NextResponse } from "next/server" import type { Channel } from "@/lib/agents" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" -// Backend API URL - configurable via BACKEND_API_URL env var const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -// Backend response structure interface BackendChannel { id: string clientId: number @@ -17,13 +16,13 @@ interface BackendChannel { export async function GET(req: NextRequest) { try { - const channelsUrl = `${BACKEND_API_URL}/api/channels?clientId=1` + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } - const response = await fetch(channelsUrl, { + const response = await fetch(`${BACKEND_API_URL}/api/channels`, { method: "GET", - headers: { - "Content-Type": "application/json", - }, + headers: buildBackendHeaders(req), }) if (!response.ok) { @@ -36,7 +35,6 @@ export async function GET(req: NextRequest) { const backendChannels: BackendChannel[] = await response.json() - // Transform backend channels to frontend Channel interface const frontendChannels: Channel[] = backendChannels.map((channel) => ({ id: channel.id, name: channel.name, diff --git a/frontend/app/api/copilotkit/[agentId]/route.ts b/frontend/app/api/copilotkit/[agentId]/route.ts index 36b49240..43d58475 100644 --- a/frontend/app/api/copilotkit/[agentId]/route.ts +++ b/frontend/app/api/copilotkit/[agentId]/route.ts @@ -5,6 +5,7 @@ import { copilotRuntimeNextJSAppRouterEndpoint, } from "@copilotkit/runtime" import { NextRequest } from "next/server" +import { getAccessToken } from "@/lib/server/auth" // Backend API URL - use BACKEND_API_URL for consistency // The /api/agents/{id}/agui endpoint is for single agent invocation @@ -14,9 +15,20 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { + const token = getAccessToken(req) + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } + const { agentId } = await params const aguiUrl = `${BACKEND_API_URL}/api/agents/${agentId}/agui` - const aguiAgent = new HttpAgent({ url: aguiUrl }) + const aguiAgent = new HttpAgent({ + url: aguiUrl, + headers: { Authorization: `Bearer ${token}` }, + }) const runtime = new CopilotRuntime({ agents: { aguiAgent } as any, }) diff --git a/frontend/app/api/copilotkit/route.ts b/frontend/app/api/copilotkit/route.ts index 4c02fbb9..bce05355 100644 --- a/frontend/app/api/copilotkit/route.ts +++ b/frontend/app/api/copilotkit/route.ts @@ -5,19 +5,30 @@ import { copilotRuntimeNextJSAppRouterEndpoint, } from "@copilotkit/runtime" import { NextRequest } from "next/server" +import { getAccessToken } from "@/lib/server/auth" // Backend API URL - use BACKEND_API_URL for consistency // The /api/agents/{id}/agui endpoint is for single agent invocation const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -const aguiUrl = `${BACKEND_API_URL}/api/agents/default/agui` -const aguiAgent = new HttpAgent({ url: aguiUrl }) +export async function POST(req: NextRequest) { + const token = getAccessToken(req) + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } -const runtime = new CopilotRuntime({ - agents: { aguiAgent } as any, -}) + const aguiUrl = `${BACKEND_API_URL}/api/agents/default/agui` + const aguiAgent = new HttpAgent({ + url: aguiUrl, + headers: { Authorization: `Bearer ${token}` }, + }) + const runtime = new CopilotRuntime({ + agents: { aguiAgent } as any, + }) -export async function POST(req: NextRequest) { const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ runtime, serviceAdapter: new ExperimentalEmptyAdapter(), diff --git a/frontend/app/api/providers/[id]/models/route.ts b/frontend/app/api/providers/[id]/models/route.ts index 446535b6..7830fd28 100644 --- a/frontend/app/api/providers/[id]/models/route.ts +++ b/frontend/app/api/providers/[id]/models/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params if (!id) { @@ -16,10 +21,9 @@ export async function GET( ) } - const response = await fetch( - `${BACKEND_API_URL}/api/providers/${id}/models`, - { headers: { "Content-Type": "application/json" } } - ) + const response = await fetch(`${BACKEND_API_URL}/api/providers/${id}/models`, { + headers: buildBackendHeaders(req), + }) if (!response.ok) { const text = await response.text() diff --git a/frontend/app/api/providers/[id]/route.ts b/frontend/app/api/providers/[id]/route.ts index 26878be2..9ecc60bb 100644 --- a/frontend/app/api/providers/[id]/route.ts +++ b/frontend/app/api/providers/[id]/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params if (!id) { @@ -18,6 +23,7 @@ export async function DELETE( const response = await fetch(`${BACKEND_API_URL}/api/providers/${id}`, { method: "DELETE", + headers: buildBackendHeaders(req), }) if (!response.ok) { diff --git a/frontend/app/api/providers/[id]/test/route.ts b/frontend/app/api/providers/[id]/test/route.ts index 2aea7ac3..300251ba 100644 --- a/frontend/app/api/providers/[id]/test/route.ts +++ b/frontend/app/api/providers/[id]/test/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" export async function POST( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params if (!id) { @@ -18,6 +23,7 @@ export async function POST( const response = await fetch(`${BACKEND_API_URL}/api/providers/${id}/test`, { method: "POST", + headers: buildBackendHeaders(req), }) const data = await response.json().catch(() => ({})) diff --git a/frontend/app/api/providers/route.ts b/frontend/app/api/providers/route.ts index 9c4f7603..7c7b0af1 100644 --- a/frontend/app/api/providers/route.ts +++ b/frontend/app/api/providers/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" @@ -6,7 +7,7 @@ interface BackendProviderConfig { id: string clientId: number name: string - providerType: number + providerType: number | string hasApiKey: boolean apiEndpoint: string | null defaultModel: string | null @@ -24,15 +25,22 @@ const PROVIDER_TYPE_TO_NAME: Record = { 500: "AzureFoundry", } +function mapProviderType(type: number | string): string { + if (typeof type === "number") { + return PROVIDER_TYPE_TO_NAME[type] ?? "OpenAI" + } + return type +} + export async function GET(req: NextRequest) { try { - const { searchParams } = new URL(req.url) - const clientId = searchParams.get("clientId") ?? "1" - const url = `${BACKEND_API_URL}/api/providers?clientId=${clientId}` + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } - const response = await fetch(url, { + const response = await fetch(`${BACKEND_API_URL}/api/providers`, { method: "GET", - headers: { "Content-Type": "application/json" }, + headers: buildBackendHeaders(req), }) if (!response.ok) { @@ -46,27 +54,24 @@ export async function GET(req: NextRequest) { const backend: BackendProviderConfig[] = await response.json() const providers = backend - .map((p) => { - const typeName = PROVIDER_TYPE_TO_NAME[p.providerType] ?? "OpenAI" - return { - backendId: p.id, - id: p.id, - name: p.name, - providerType: typeName, - status: p.isEnabled ? "enabled" : "disabled", - modelsCount: p.modelsCount ?? 0, - apiKey: "", - hasApiKey: p.hasApiKey ?? false, - baseUrl: p.apiEndpoint ?? "", - defaultModel: p.defaultModel ?? "", - selectedModels: p.selectedModels ? JSON.parse(p.selectedModels) as string[] : [], - timeout: 30, - rateLimit: 60, - availableModels: [] as string[], - isDefault: false, - dateCreated: p.dateCreated, - } - }) + .map((p) => ({ + backendId: p.id, + id: p.id, + name: p.name, + providerType: mapProviderType(p.providerType), + status: p.isEnabled ? "enabled" : "disabled", + modelsCount: p.modelsCount ?? 0, + apiKey: "", + hasApiKey: p.hasApiKey ?? false, + baseUrl: p.apiEndpoint ?? "", + defaultModel: p.defaultModel ?? "", + selectedModels: p.selectedModels ? (JSON.parse(p.selectedModels) as string[]) : [], + timeout: 30, + rateLimit: 60, + availableModels: [] as string[], + isDefault: false, + dateCreated: p.dateCreated, + })) .sort((a, b) => { const tA = a.dateCreated ? new Date(a.dateCreated).getTime() : 0 const tB = b.dateCreated ? new Date(b.dateCreated).getTime() : 0 diff --git a/frontend/app/api/providers/test/route.ts b/frontend/app/api/providers/test/route.ts index 9f3f3b93..81a3e1e0 100644 --- a/frontend/app/api/providers/test/route.ts +++ b/frontend/app/api/providers/test/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" @@ -15,7 +16,11 @@ const PROVIDER_TYPE_FROM_NAME: Record = { export async function POST(req: NextRequest) { try { - const body = await req.json() as { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = (await req.json()) as { providerType?: string name?: string apiKey?: string @@ -24,8 +29,8 @@ export async function POST(req: NextRequest) { defaultModel?: string providerId?: string } - const providerType = - PROVIDER_TYPE_FROM_NAME[body.providerType ?? ""] ?? 100 + + const providerType = PROVIDER_TYPE_FROM_NAME[body.providerType ?? ""] ?? 100 const apiEndpoint = body.apiEndpoint ?? body.baseUrl ?? "" const backendBody = { providerType, @@ -35,11 +40,13 @@ export async function POST(req: NextRequest) { defaultModel: body.defaultModel ?? null, name: body.name ?? null, } + const response = await fetch(`${BACKEND_API_URL}/api/providers/test`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: buildBackendHeaders(req), body: JSON.stringify(backendBody), }) + const data = await response.json().catch(() => ({})) if (!response.ok) { return NextResponse.json( @@ -47,6 +54,7 @@ export async function POST(req: NextRequest) { { status: response.status } ) } + return NextResponse.json(data) } catch (error) { console.error("Error testing provider connection:", error) diff --git a/frontend/app/api/settings/route.ts b/frontend/app/api/settings/route.ts index 6dae4674..55f6208a 100644 --- a/frontend/app/api/settings/route.ts +++ b/frontend/app/api/settings/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" @@ -28,6 +29,10 @@ interface FrontendProvider { export async function PUT(req: NextRequest) { try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const body = await req.json() const { providers } = body as { providers: FrontendProvider[] } @@ -38,7 +43,6 @@ export async function PUT(req: NextRequest) { ) } - const clientId = 1 const results: { id: string; backendId: string }[] = [] for (const p of providers) { @@ -47,10 +51,9 @@ export async function PUT(req: NextRequest) { const isEnabled = p.status !== "disabled" if (p.backendId) { - const updateUrl = `${BACKEND_API_URL}/api/providers/${p.backendId}` - const res = await fetch(updateUrl, { + const res = await fetch(`${BACKEND_API_URL}/api/providers/${p.backendId}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, + headers: buildBackendHeaders(req), body: JSON.stringify({ name: p.name, apiKey: p.apiKey, @@ -69,12 +72,10 @@ export async function PUT(req: NextRequest) { } results.push({ id: p.id, backendId: p.backendId }) } else { - const createUrl = `${BACKEND_API_URL}/api/providers/create` - const res = await fetch(createUrl, { + const res = await fetch(`${BACKEND_API_URL}/api/providers/create`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: buildBackendHeaders(req), body: JSON.stringify({ - clientId, name: p.name, providerType, apiKey: p.apiKey, diff --git a/frontend/app/api/users/route.ts b/frontend/app/api/users/route.ts new file mode 100644 index 00000000..86da4cc4 --- /dev/null +++ b/frontend/app/api/users/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server" +import { buildBackendHeaders, getAccessToken } from "@/lib/server/auth" +import type { HumanMember } from "@/lib/humans" +import { toHumanCardId } from "@/lib/humans" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +interface BackendUserPresence { + id: number + displayName: string + email: string + isOnline: boolean + isCurrentUser: boolean + lastSeenAtUtc: string | null +} + +export async function GET(req: NextRequest) { + try { + if (!getAccessToken(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const response = await fetch(`${BACKEND_API_URL}/api/users`, { + method: "GET", + headers: buildBackendHeaders(req), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + return NextResponse.json( + data?.error ? { error: data.error } : { error: "Failed to fetch users" }, + { status: response.status } + ) + } + + const users = (data as BackendUserPresence[]).map( + (user): HumanMember => ({ + id: toHumanCardId(user.id), + userId: user.id, + name: user.displayName, + email: user.email, + status: user.isOnline ? "Online" : "Offline", + isCurrentUser: user.isCurrentUser, + }) + ) + + return NextResponse.json(users) + } catch (error) { + console.error("Error fetching users:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index bb407c83..951e0249 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -132,22 +132,3 @@ .direct-messages-area .poweredBy { display: none !important; } - -/* Next.js dev tools portal: same place as CopilotKit web inspector (left sidebar, above Settings), on top */ -nextjs-portal { - position: fixed !important; - left: 2px !important; - bottom: 60px !important; - top: auto !important; - right: auto !important; - z-index: 2147483647 !important; -} - -/* CopilotKit web inspector: inside left sidebar, just above Settings button (py-3 + gap-2 + 40px ≈ 60px from bottom) */ -cpk-web-inspector { - left: 2px !important; - top: auto !important; - right: auto !important; - bottom: 60px !important; - transform: none !important; -} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 00000000..2cb0ef9f --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,103 @@ +"use client" + +import { FormEvent, Suspense, useState } from "react" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" + +function LoginPageContent() { + const router = useRouter() + const searchParams = useSearchParams() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setError(null) + setIsSubmitting(true) + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + setError(data?.error ?? "Login failed") + return + } + + const nextPath = searchParams.get("next") || "/" + router.replace(nextPath) + router.refresh() + } catch { + setError("Unable to login. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+

Login

+ + Sign up + +
+ +
+ + + + + {error &&

{error}

} + + +
+
+
+ ) +} + +export default function LoginPage() { + return ( + + + + ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 35badcf6..787c64eb 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -7,6 +7,7 @@ import { ChannelSidebar } from "@/components/channel-sidebar" import { ChannelArea } from "@/components/channel-area" import { DirectMessagesView } from "@/components/direct-messages-view" import { SettingsView, getDisplayNameFromStorage } from "@/components/settings/settings-view" +import type { HumanMember } from "@/lib/humans" export default function Home() { const [viewMode, setViewMode] = useState("channels") @@ -14,6 +15,7 @@ export default function Home() { const [isLoadingAgents, setIsLoadingAgents] = useState(true) const [channels, setChannels] = useState([]) const [isLoadingChannels, setIsLoadingChannels] = useState(true) + const [humans, setHumans] = useState([]) const [activeChannelId, setActiveChannelId] = useState("") const [activeDMId, setActiveDMId] = useState(null) const [pendingDMMessage, setPendingDMMessage] = useState(null) @@ -27,12 +29,29 @@ export default function Home() { if (viewMode !== "settings") setDisplayName(getDisplayNameFromStorage()) }, [viewMode]) + useEffect(() => { + let isCancelled = false + + async function ensureAuthenticated() { + const response = await fetch("/api/auth/me") + if (!response.ok && !isCancelled) { + await fetch("/api/auth/logout", { method: "POST" }) + window.location.href = "/login" + } + } + + void ensureAuthenticated() + return () => { + isCancelled = true + } + }, []) + // Load agents from backend on mount useEffect(() => { async function loadAgents() { try { setIsLoadingAgents(true) - const response = await fetch("/api/agents?clientId=1") + const response = await fetch("/api/agents") if (response.ok) { const backendAgents: Agent[] = await response.json() if (backendAgents.length > 0) { @@ -53,7 +72,7 @@ export default function Home() { async function loadChannels() { try { setIsLoadingChannels(true) - const response = await fetch("/api/channels?clientId=1") + const response = await fetch("/api/channels") if (response.ok) { const backendChannels: Channel[] = await response.json() if (backendChannels.length > 0) { @@ -71,6 +90,35 @@ export default function Home() { loadChannels() }, []) + // Load registered users and refresh presence periodically. + useEffect(() => { + let isCancelled = false + + async function loadHumans() { + try { + const response = await fetch("/api/users") + if (!response.ok) return + + const users: HumanMember[] = await response.json() + if (!isCancelled) { + setHumans(users) + } + } catch (error) { + console.error("Failed to load users from backend:", error) + } + } + + void loadHumans() + const interval = window.setInterval(() => { + void loadHumans() + }, 30000) + + return () => { + isCancelled = true + window.clearInterval(interval) + } + }, []) + const handleCreateChannel = useCallback(async (name: string) => { try { const response = await fetch("/api/channels/create", { @@ -133,7 +181,7 @@ export default function Home() { const handleAddAgent = useCallback(async (agent: Agent) => { // Reload agents from backend after creation to ensure consistency try { - const response = await fetch("/api/agents?clientId=1") + const response = await fetch("/api/agents") if (response.ok) { const backendAgents: Agent[] = await response.json() if (backendAgents.length > 0) { @@ -176,11 +224,17 @@ export default function Home() { setPendingDMMessage(message ?? null) }, []) + const handleLogout = useCallback(async () => { + await fetch("/api/auth/logout", { method: "POST" }) + window.location.href = "/login" + }, []) + return (
{viewMode === "channels" && ( @@ -197,6 +251,7 @@ export default function Home() { key={activeChannel.id} channel={activeChannel} allAgents={agents} + humans={humans} displayName={displayName} onUpdateChannel={handleUpdateChannel} onAddAgent={handleAddAgent} @@ -226,7 +281,7 @@ export default function Home() { activeDMId={activeDMId} setActiveDMId={setActiveDMId} agents={agents} - displayName={displayName} + humans={humans} pendingDMMessage={pendingDMMessage} onClearPendingDMMessage={() => setPendingDMMessage(null)} /> diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx new file mode 100644 index 00000000..bed0f706 --- /dev/null +++ b/frontend/app/signup/page.tsx @@ -0,0 +1,129 @@ +"use client" + +import { FormEvent, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" + +export default function SignupPage() { + const router = useRouter() + const [displayName, setDisplayName] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setError(null) + + if (password !== confirmPassword) { + setError("Passwords do not match") + return + } + + setIsSubmitting(true) + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password, + displayName: displayName.trim() || undefined, + }), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + setError(data?.error ?? "Registration failed") + return + } + + router.replace("/") + router.refresh() + } catch { + setError("Unable to register. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+

Sign up

+ + Login + +
+ +
+ + + + + + + + + {error &&

{error}

} + + +
+
+
+ ) +} diff --git a/frontend/components/channel-area.tsx b/frontend/components/channel-area.tsx index 9478f03c..88310a29 100644 --- a/frontend/components/channel-area.tsx +++ b/frontend/components/channel-area.tsx @@ -8,10 +8,12 @@ import { MessageInput } from "@/components/message-input" import { MemberList } from "@/components/member-list" import type { AGUIEvent } from "@ag-ui/core" import { EventType } from "@ag-ui/core" +import type { HumanMember } from "@/lib/humans" interface ChannelAreaProps { channel: Channel allAgents: Agent[] + humans: HumanMember[] displayName: string onUpdateChannel: (channel: Channel) => void onAddAgent: (agent: Agent) => void @@ -23,6 +25,7 @@ interface ChannelAreaProps { export function ChannelArea({ channel, allAgents, + humans, displayName, onUpdateChannel, onAddAgent, @@ -257,6 +260,7 @@ export function ChannelArea({ {showMembers && ( {children} + } + const runtimeUrl = agentId ? `/api/copilotkit/${agentId}` : "/api/copilotkit" + return ( {children} diff --git a/frontend/components/direct-messages-right-pane.tsx b/frontend/components/direct-messages-right-pane.tsx index b483682d..32bdc566 100644 --- a/frontend/components/direct-messages-right-pane.tsx +++ b/frontend/components/direct-messages-right-pane.tsx @@ -2,28 +2,13 @@ import { User } from "lucide-react" import type { Agent } from "@/lib/agents" - -export const HUMAN_ID = "user" - -type Human = { id: string; name: string; status: "Online" | "Offline" } - -export const HUMANS: Human[] = [ - { id: HUMAN_ID, name: "You", status: "Online" }, - { id: "human:alex-c", name: "Alex C", status: "Offline" }, - { id: "human:alex-k", name: "Alex K", status: "Offline" }, - { id: "human:illya", name: "Illya", status: "Offline" }, - { id: "human:ivan", name: "Ivan", status: "Offline" }, -] - -export function isHumanId(id: string): boolean { - return HUMANS.some((h) => h.id === id) -} +import type { HumanMember } from "@/lib/humans" interface DirectMessagesRightPaneProps { - /** Agent id or human id (e.g. "user", "human:alex-c"); null to show nothing. */ + /** Agent id or human id (e.g. "human:1"). null to show nothing. */ selectedCardId: string | null agents: Agent[] - displayName: string + humans: HumanMember[] } function AgentCard({ agent }: { agent: Agent }) { @@ -193,7 +178,7 @@ function HumanCard({ export function DirectMessagesRightPane({ selectedCardId, agents, - displayName, + humans, }: DirectMessagesRightPaneProps) { if (!selectedCardId) { return ( @@ -209,7 +194,7 @@ export function DirectMessagesRightPane({ ) } - const selectedHuman = HUMANS.find((h) => h.id === selectedCardId) + const selectedHuman = humans.find((h) => h.id === selectedCardId) if (selectedHuman) { return (
void } export function DirectMessagesSidebar({ agents, + humans, activeId, selectedCardId, - displayName, onSelect, }: DirectMessagesSidebarProps) { const [search, setSearch] = useState("") @@ -49,11 +49,20 @@ export function DirectMessagesSidebar({ }, [conversations, search]) const filteredHumans = useMemo(() => { - const others = HUMANS.filter((h) => h.id !== HUMAN_ID) - if (!search.trim()) return others + const sorted = [...humans].sort((a, b) => { + if (a.isCurrentUser !== b.isCurrentUser) return a.isCurrentUser ? -1 : 1 + if (a.status !== b.status) return a.status === "Online" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + if (!search.trim()) return sorted const q = search.toLowerCase() - return others.filter((h) => h.name.toLowerCase().includes(q)) - }, [search]) + return sorted.filter( + (h) => + h.name.toLowerCase().includes(q) || + h.email.toLowerCase().includes(q) + ) + }, [humans, search]) return (
- {human.id === HUMAN_ID ? displayName : human.name} + {human.name} - {human.status === "Online" && ( - - )} + {human.status} diff --git a/frontend/components/direct-messages-view.tsx b/frontend/components/direct-messages-view.tsx index 040bca14..12c67f79 100644 --- a/frontend/components/direct-messages-view.tsx +++ b/frontend/components/direct-messages-view.tsx @@ -5,14 +5,16 @@ import { useCopilotContext } from "@copilotkit/react-core" import { useAgentRuntime } from "@/contexts/agent-runtime-context" import { DirectMessagesSidebar } from "@/components/direct-messages-sidebar" import { DirectMessagesArea } from "@/components/direct-messages-area" -import { DirectMessagesRightPane, HUMAN_ID } from "@/components/direct-messages-right-pane" +import { DirectMessagesRightPane } from "@/components/direct-messages-right-pane" import type { Agent } from "@/lib/agents" +import type { HumanMember } from "@/lib/humans" +import { isHumanCardId } from "@/lib/humans" interface DirectMessagesViewProps { activeDMId: string | null setActiveDMId: (id: string | null) => void agents: Agent[] - displayName: string + humans: HumanMember[] pendingDMMessage?: string | null onClearPendingDMMessage?: () => void } @@ -21,7 +23,7 @@ export function DirectMessagesView({ activeDMId, setActiveDMId, agents, - displayName, + humans, pendingDMMessage = null, onClearPendingDMMessage, }: DirectMessagesViewProps) { @@ -39,7 +41,7 @@ export function DirectMessagesView({ const handleSelectDM = useCallback( (id: string) => { setSelectedCardId(id) - if (id === HUMAN_ID) { + if (isHumanCardId(id)) { return } setThreadId(id) @@ -53,9 +55,9 @@ export function DirectMessagesView({ }, [effectiveId, setThreadId]) useEffect(() => { - if (activeDMId && activeDMId !== HUMAN_ID) { + if (activeDMId) { setSelectedCardId((prev) => - prev === HUMAN_ID ? prev : activeDMId + prev && isHumanCardId(prev) ? prev : activeDMId ) } }, [activeDMId]) @@ -64,9 +66,9 @@ export function DirectMessagesView({ <> )} diff --git a/frontend/components/icon-sidebar.tsx b/frontend/components/icon-sidebar.tsx index 87c8d0bb..1c572c55 100644 --- a/frontend/components/icon-sidebar.tsx +++ b/frontend/components/icon-sidebar.tsx @@ -1,6 +1,6 @@ "use client" -import { Settings, MessageCircle, Hash } from "lucide-react" +import { Settings, MessageCircle, Hash, LogOut } from "lucide-react" import { Tooltip, TooltipContent, @@ -14,11 +14,13 @@ export type ViewMode = "channels" | "direct-messages" | "settings" interface IconSidebarProps { viewMode: ViewMode onViewChange: (view: ViewMode) => void + onLogout: () => void | Promise } export function IconSidebar({ viewMode, onViewChange, + onLogout, }: IconSidebarProps) { const topItems = [ { icon: Hash, label: "Direct messages", onClick: () => onViewChange("direct-messages"), active: viewMode === "direct-messages" }, @@ -84,6 +86,23 @@ export function IconSidebar({ Settings + + + + + Logout +
diff --git a/frontend/components/manage-agents-dialog.tsx b/frontend/components/manage-agents-dialog.tsx index c0c776b9..b164b014 100644 --- a/frontend/components/manage-agents-dialog.tsx +++ b/frontend/components/manage-agents-dialog.tsx @@ -64,7 +64,7 @@ export function ManageAgentsDialog({ // Fetch providers on mount useEffect(() => { - fetch("/api/providers?clientId=1") + fetch("/api/providers") .then((res) => res.json()) .then((data: Provider[]) => { setProviders(data.filter((p) => p.status === "enabled")) diff --git a/frontend/components/member-list.tsx b/frontend/components/member-list.tsx index 6fdd0258..23f85819 100644 --- a/frontend/components/member-list.tsx +++ b/frontend/components/member-list.tsx @@ -25,7 +25,7 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" -import { HUMANS, HUMAN_ID } from "@/components/direct-messages-right-pane" +import type { HumanMember } from "@/lib/humans" import { Search, User, Plus, Loader2 } from "lucide-react" function MemberContextMenu({ @@ -80,6 +80,7 @@ function MemberContextMenu({ interface MemberListProps { allAgents: Agent[] + humans: HumanMember[] activeAgentIds: string[] streamingAgentId?: string | null displayName: string @@ -174,6 +175,7 @@ function AgentRow({ export function MemberList({ allAgents, + humans, activeAgentIds, streamingAgentId = null, displayName, @@ -216,9 +218,9 @@ export function MemberList({ const filteredWorking = workingAgents.filter(matchesSearch) const filteredAvailable = availableAgents.filter(matchesSearch) const currentUserName = displayName || "You" - const filteredHumans = HUMANS.filter((h) => { + const filteredHumans = humans.filter((h) => { if (!query) return true - const name = h.id === HUMAN_ID ? currentUserName : h.name + const name = h.isCurrentUser ? currentUserName : h.name return name.toLowerCase().includes(query) }) const matchesHuman = filteredHumans.length > 0 @@ -337,7 +339,7 @@ export function MemberList({ Humans {filteredHumans.map((human) => - human.id === HUMAN_ID ? ( + human.isCurrentUser ? ( - {human.status === "Online" && ( - - )} + {human.status} @@ -577,7 +581,11 @@ export function MemberList({ color: "hsl(210, 3%, 90%)", }} > - + Human diff --git a/frontend/components/settings/settings-view.tsx b/frontend/components/settings/settings-view.tsx index 390bcbc5..ccb3b5df 100644 --- a/frontend/components/settings/settings-view.tsx +++ b/frontend/components/settings/settings-view.tsx @@ -72,7 +72,7 @@ export function SettingsView({ useEffect(() => { const persisted = loadPersistedSettings() - fetch("/api/providers?clientId=1") + fetch("/api/providers") .then((res) => { if (!res.ok) return null return res.json() diff --git a/frontend/lib/humans.ts b/frontend/lib/humans.ts new file mode 100644 index 00000000..cc8e813f --- /dev/null +++ b/frontend/lib/humans.ts @@ -0,0 +1,20 @@ +export type HumanStatus = "Online" | "Offline" + +export interface HumanMember { + id: string + userId: number + name: string + email: string + status: HumanStatus + isCurrentUser: boolean +} + +const HUMAN_ID_PREFIX = "human:" + +export function toHumanCardId(userId: number): string { + return `${HUMAN_ID_PREFIX}${userId}` +} + +export function isHumanCardId(id: string): boolean { + return id.startsWith(HUMAN_ID_PREFIX) +} diff --git a/frontend/lib/server/auth.ts b/frontend/lib/server/auth.ts new file mode 100644 index 00000000..4b0fa398 --- /dev/null +++ b/frontend/lib/server/auth.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server" + +export const ACCESS_TOKEN_COOKIE_NAME = "aoc_access_token" +export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 8 + +export function getAccessToken(req: NextRequest): string | null { + return req.cookies.get(ACCESS_TOKEN_COOKIE_NAME)?.value ?? null +} + +export function buildBackendHeaders( + req: NextRequest, + options?: { includeContentType?: boolean; extraHeaders?: HeadersInit } +): Headers { + const headers = new Headers(options?.extraHeaders) + + if (options?.includeContentType ?? true) { + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json") + } + } + + const token = getAccessToken(req) + if (token) { + headers.set("Authorization", `Bearer ${token}`) + } + + return headers +} + +export function getAuthCookieOptions() { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" as const, + path: "/", + maxAge: ACCESS_TOKEN_TTL_SECONDS, + } +} diff --git a/frontend/proxy.ts b/frontend/proxy.ts new file mode 100644 index 00000000..80405055 --- /dev/null +++ b/frontend/proxy.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME } from "@/lib/server/auth" + +const PUBLIC_ROUTES = new Set(["/login", "/signup"]) + +export function proxy(req: NextRequest) { + const { pathname } = req.nextUrl + + if ( + pathname.startsWith("/_next") || + pathname.startsWith("/favicon.ico") || + pathname.startsWith("/placeholder") + ) { + return NextResponse.next() + } + + if (pathname.startsWith("/api/auth")) { + return NextResponse.next() + } + + const hasToken = Boolean(req.cookies.get(ACCESS_TOKEN_COOKIE_NAME)?.value) + + if (pathname.startsWith("/api")) { + if (!hasToken) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + return NextResponse.next() + } + + if (PUBLIC_ROUTES.has(pathname)) { + if (hasToken) { + return NextResponse.redirect(new URL("/", req.url)) + } + return NextResponse.next() + } + + if (!hasToken) { + const loginUrl = new URL("/login", req.url) + loginUrl.searchParams.set("next", pathname) + return NextResponse.redirect(loginUrl) + } + + return NextResponse.next() +} + +export const config = { + matcher: ["/((?!_next/static|_next/image).*)"], +} From 398be82a908d498f6525ac06d9fee588e742b17c Mon Sep 17 00:00:00 2001 From: Ilia Date: Fri, 20 Feb 2026 22:21:07 -0600 Subject: [PATCH 02/37] fix: server-render humans list to prevent refresh flicker --- frontend/app/page.tsx | 339 ++++------------------- frontend/components/home-page-client.tsx | 303 ++++++++++++++++++++ 2 files changed, 351 insertions(+), 291 deletions(-) create mode 100644 frontend/components/home-page-client.tsx diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 787c64eb..c9c91e84 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,299 +1,56 @@ -"use client" - -import { useState, useCallback, useEffect } from "react" -import { type Agent, type Channel } from "@/lib/agents" -import { IconSidebar, type ViewMode } from "@/components/icon-sidebar" -import { ChannelSidebar } from "@/components/channel-sidebar" -import { ChannelArea } from "@/components/channel-area" -import { DirectMessagesView } from "@/components/direct-messages-view" -import { SettingsView, getDisplayNameFromStorage } from "@/components/settings/settings-view" -import type { HumanMember } from "@/lib/humans" - -export default function Home() { - const [viewMode, setViewMode] = useState("channels") - const [agents, setAgents] = useState([]) - const [isLoadingAgents, setIsLoadingAgents] = useState(true) - const [channels, setChannels] = useState([]) - const [isLoadingChannels, setIsLoadingChannels] = useState(true) - const [humans, setHumans] = useState([]) - const [activeChannelId, setActiveChannelId] = useState("") - const [activeDMId, setActiveDMId] = useState(null) - const [pendingDMMessage, setPendingDMMessage] = useState(null) - const [displayName, setDisplayName] = useState(() => - typeof window !== "undefined" ? getDisplayNameFromStorage() : "User" - ) - const activeChannel = channels.find((c) => c.id === activeChannelId) ?? channels[0] - - // Refresh display name from persisted settings when returning from Settings - useEffect(() => { - if (viewMode !== "settings") setDisplayName(getDisplayNameFromStorage()) - }, [viewMode]) - - useEffect(() => { - let isCancelled = false - - async function ensureAuthenticated() { - const response = await fetch("/api/auth/me") - if (!response.ok && !isCancelled) { - await fetch("/api/auth/logout", { method: "POST" }) - window.location.href = "/login" - } - } - - void ensureAuthenticated() - return () => { - isCancelled = true - } - }, []) - - // Load agents from backend on mount - useEffect(() => { - async function loadAgents() { - try { - setIsLoadingAgents(true) - const response = await fetch("/api/agents") - if (response.ok) { - const backendAgents: Agent[] = await response.json() - if (backendAgents.length > 0) { - setAgents(backendAgents) - } - } - } catch (error) { - console.error("Failed to load agents from backend:", error) - } finally { - setIsLoadingAgents(false) - } - } - loadAgents() - }, []) - - // Load channels from backend on mount - useEffect(() => { - async function loadChannels() { - try { - setIsLoadingChannels(true) - const response = await fetch("/api/channels") - if (response.ok) { - const backendChannels: Channel[] = await response.json() - if (backendChannels.length > 0) { - setChannels(backendChannels) - // Set active channel to first backend channel - setActiveChannelId(backendChannels[0].id) - } - } - } catch (error) { - console.error("Failed to load channels from backend:", error) - } finally { - setIsLoadingChannels(false) - } - } - loadChannels() - }, []) - - // Load registered users and refresh presence periodically. - useEffect(() => { - let isCancelled = false - - async function loadHumans() { - try { - const response = await fetch("/api/users") - if (!response.ok) return - - const users: HumanMember[] = await response.json() - if (!isCancelled) { - setHumans(users) - } - } catch (error) { - console.error("Failed to load users from backend:", error) - } - } - - void loadHumans() - const interval = window.setInterval(() => { - void loadHumans() - }, 30000) - - return () => { - isCancelled = true - window.clearInterval(interval) - } - }, []) - - const handleCreateChannel = useCallback(async (name: string) => { - try { - const response = await fetch("/api/channels/create", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, agentIds: [] }), - }) - - if (response.ok) { - const newChannel: Channel = await response.json() - setChannels((prev) => [...prev, newChannel]) - setActiveChannelId(newChannel.id) - } else { - // Fallback to local creation - const id = crypto.randomUUID() - const newChannel: Channel = { id, name, agentIds: [], dateCreated: new Date().toISOString() } - setChannels((prev) => [...prev, newChannel]) - setActiveChannelId(id) - } - } catch (error) { - console.error("Failed to create channel:", error) - // Fallback to local creation - const id = crypto.randomUUID() - const newChannel: Channel = { id, name, agentIds: [], dateCreated: new Date().toISOString() } - setChannels((prev) => [...prev, newChannel]) - setActiveChannelId(id) - } - }, []) - - const handleUpdateChannel = useCallback((updatedChannel: Channel) => { - setChannels((prev) => - prev.map((c) => (c.id === updatedChannel.id ? updatedChannel : c)) - ) - }, []) +import { cookies } from "next/headers" +import HomePageClient from "@/components/home-page-client" +import { toHumanCardId, type HumanMember } from "@/lib/humans" +import { ACCESS_TOKEN_COOKIE_NAME } from "@/lib/server/auth" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +interface BackendUserPresence { + id: number + displayName: string + email: string + isOnline: boolean + isCurrentUser: boolean +} - const handleDeleteChannel = useCallback(async (channelId: string) => { - try { - const response = await fetch(`/api/channels/${channelId}`, { - method: "DELETE", - }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error ?? "Failed to delete channel") - } - setChannels((prev) => { - const next = prev.filter((c) => c.id !== channelId) - return next - }) - setActiveChannelId((current) => { - if (current !== channelId) return current - const remaining = channels.filter((c) => c.id !== channelId) - return remaining[0]?.id ?? "" - }) - } catch (error) { - console.error("Failed to delete channel:", error) - throw error - } - }, [channels]) +export const dynamic = "force-dynamic" - const handleAddAgent = useCallback(async (agent: Agent) => { - // Reload agents from backend after creation to ensure consistency - try { - const response = await fetch("/api/agents") - if (response.ok) { - const backendAgents: Agent[] = await response.json() - if (backendAgents.length > 0) { - setAgents(backendAgents) - } - } - } catch (error) { - console.error("Failed to reload agents from backend:", error) - // Fallback to local update - setAgents((prev) => [...prev, agent]) - } - }, []) +async function loadInitialHumans(): Promise { + const cookieStore = await cookies() + const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE_NAME)?.value - const handleUpdateAgent = useCallback((agent: Agent) => { - setAgents((prev) => - prev.map((a) => (a.id === agent.id ? agent : a)) - ) - }, []) + if (!accessToken) { + return [] + } - const handleDeleteAgent = useCallback(async (agentId: string) => { - const response = await fetch(`/api/agents/${agentId}`, { - method: "DELETE", + try { + const response = await fetch(`${BACKEND_API_URL}/api/users`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + cache: "no-store", }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error ?? "Failed to delete agent") - } - setAgents((prev) => prev.filter((a) => a.id !== agentId)) - setChannels((prev) => - prev.map((c) => ({ - ...c, - agentIds: c.agentIds.filter((id) => id !== agentId), - })) - ) - }, []) - const handleOpenAgentInDM = useCallback((agentId: string, message?: string) => { - setViewMode("direct-messages") - setActiveDMId(agentId) - setPendingDMMessage(message ?? null) - }, []) - - const handleLogout = useCallback(async () => { - await fetch("/api/auth/logout", { method: "POST" }) - window.location.href = "/login" - }, []) - - return ( -
- + if (!response.ok) { + return [] + } + + const data = (await response.json()) as BackendUserPresence[] + return data.map((user): HumanMember => ({ + id: toHumanCardId(user.id), + userId: user.id, + name: user.displayName, + email: user.email, + status: user.isOnline ? "Online" : "Offline", + isCurrentUser: user.isCurrentUser, + })) + } catch { + return [] + } +} - {viewMode === "channels" && ( - <> - - {activeChannel ? ( - - ) : isLoadingChannels ? ( -
-
Loading channels...
-
- ) : ( -
-
No channels found
-
- )} - - )} - {viewMode === "direct-messages" && ( - setPendingDMMessage(null)} - /> - )} - {viewMode === "settings" && ( - - )} -
- ) +export default async function HomePage() { + const initialHumans = await loadInitialHumans() + return } diff --git a/frontend/components/home-page-client.tsx b/frontend/components/home-page-client.tsx new file mode 100644 index 00000000..e526671d --- /dev/null +++ b/frontend/components/home-page-client.tsx @@ -0,0 +1,303 @@ +"use client" + +import { useState, useCallback, useEffect } from "react" +import { type Agent, type Channel } from "@/lib/agents" +import { IconSidebar, type ViewMode } from "@/components/icon-sidebar" +import { ChannelSidebar } from "@/components/channel-sidebar" +import { ChannelArea } from "@/components/channel-area" +import { DirectMessagesView } from "@/components/direct-messages-view" +import { SettingsView, getDisplayNameFromStorage } from "@/components/settings/settings-view" +import type { HumanMember } from "@/lib/humans" + +interface HomePageClientProps { + initialHumans: HumanMember[] +} + +export default function HomePageClient({ initialHumans }: HomePageClientProps) { + const [viewMode, setViewMode] = useState("channels") + const [agents, setAgents] = useState([]) + const [isLoadingAgents, setIsLoadingAgents] = useState(true) + const [channels, setChannels] = useState([]) + const [isLoadingChannels, setIsLoadingChannels] = useState(true) + const [humans, setHumans] = useState(initialHumans) + const [activeChannelId, setActiveChannelId] = useState("") + const [activeDMId, setActiveDMId] = useState(null) + const [pendingDMMessage, setPendingDMMessage] = useState(null) + const [displayName, setDisplayName] = useState(() => + typeof window !== "undefined" ? getDisplayNameFromStorage() : "User" + ) + const activeChannel = channels.find((c) => c.id === activeChannelId) ?? channels[0] + + // Refresh display name from persisted settings when returning from Settings + useEffect(() => { + if (viewMode !== "settings") setDisplayName(getDisplayNameFromStorage()) + }, [viewMode]) + + useEffect(() => { + let isCancelled = false + + async function ensureAuthenticated() { + const response = await fetch("/api/auth/me") + if (!response.ok && !isCancelled) { + await fetch("/api/auth/logout", { method: "POST" }) + window.location.href = "/login" + } + } + + void ensureAuthenticated() + return () => { + isCancelled = true + } + }, []) + + // Load agents from backend on mount + useEffect(() => { + async function loadAgents() { + try { + setIsLoadingAgents(true) + const response = await fetch("/api/agents") + if (response.ok) { + const backendAgents: Agent[] = await response.json() + if (backendAgents.length > 0) { + setAgents(backendAgents) + } + } + } catch (error) { + console.error("Failed to load agents from backend:", error) + } finally { + setIsLoadingAgents(false) + } + } + loadAgents() + }, []) + + // Load channels from backend on mount + useEffect(() => { + async function loadChannels() { + try { + setIsLoadingChannels(true) + const response = await fetch("/api/channels") + if (response.ok) { + const backendChannels: Channel[] = await response.json() + if (backendChannels.length > 0) { + setChannels(backendChannels) + // Set active channel to first backend channel + setActiveChannelId(backendChannels[0].id) + } + } + } catch (error) { + console.error("Failed to load channels from backend:", error) + } finally { + setIsLoadingChannels(false) + } + } + loadChannels() + }, []) + + // Load registered users and refresh presence periodically. + useEffect(() => { + let isCancelled = false + + async function loadHumans() { + try { + const response = await fetch("/api/users") + if (!response.ok) return + + const users: HumanMember[] = await response.json() + if (!isCancelled) { + setHumans(users) + } + } catch (error) { + console.error("Failed to load users from backend:", error) + } + } + + void loadHumans() + const interval = window.setInterval(() => { + void loadHumans() + }, 30000) + + return () => { + isCancelled = true + window.clearInterval(interval) + } + }, []) + + const handleCreateChannel = useCallback(async (name: string) => { + try { + const response = await fetch("/api/channels/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, agentIds: [] }), + }) + + if (response.ok) { + const newChannel: Channel = await response.json() + setChannels((prev) => [...prev, newChannel]) + setActiveChannelId(newChannel.id) + } else { + // Fallback to local creation + const id = crypto.randomUUID() + const newChannel: Channel = { id, name, agentIds: [], dateCreated: new Date().toISOString() } + setChannels((prev) => [...prev, newChannel]) + setActiveChannelId(id) + } + } catch (error) { + console.error("Failed to create channel:", error) + // Fallback to local creation + const id = crypto.randomUUID() + const newChannel: Channel = { id, name, agentIds: [], dateCreated: new Date().toISOString() } + setChannels((prev) => [...prev, newChannel]) + setActiveChannelId(id) + } + }, []) + + const handleUpdateChannel = useCallback((updatedChannel: Channel) => { + setChannels((prev) => + prev.map((c) => (c.id === updatedChannel.id ? updatedChannel : c)) + ) + }, []) + + const handleDeleteChannel = useCallback(async (channelId: string) => { + try { + const response = await fetch(`/api/channels/${channelId}`, { + method: "DELETE", + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? "Failed to delete channel") + } + setChannels((prev) => { + const next = prev.filter((c) => c.id !== channelId) + return next + }) + setActiveChannelId((current) => { + if (current !== channelId) return current + const remaining = channels.filter((c) => c.id !== channelId) + return remaining[0]?.id ?? "" + }) + } catch (error) { + console.error("Failed to delete channel:", error) + throw error + } + }, [channels]) + + const handleAddAgent = useCallback(async (agent: Agent) => { + // Reload agents from backend after creation to ensure consistency + try { + const response = await fetch("/api/agents") + if (response.ok) { + const backendAgents: Agent[] = await response.json() + if (backendAgents.length > 0) { + setAgents(backendAgents) + } + } + } catch (error) { + console.error("Failed to reload agents from backend:", error) + // Fallback to local update + setAgents((prev) => [...prev, agent]) + } + }, []) + + const handleUpdateAgent = useCallback((agent: Agent) => { + setAgents((prev) => + prev.map((a) => (a.id === agent.id ? agent : a)) + ) + }, []) + + const handleDeleteAgent = useCallback(async (agentId: string) => { + const response = await fetch(`/api/agents/${agentId}`, { + method: "DELETE", + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? "Failed to delete agent") + } + setAgents((prev) => prev.filter((a) => a.id !== agentId)) + setChannels((prev) => + prev.map((c) => ({ + ...c, + agentIds: c.agentIds.filter((id) => id !== agentId), + })) + ) + }, []) + + const handleOpenAgentInDM = useCallback((agentId: string, message?: string) => { + setViewMode("direct-messages") + setActiveDMId(agentId) + setPendingDMMessage(message ?? null) + }, []) + + const handleLogout = useCallback(async () => { + await fetch("/api/auth/logout", { method: "POST" }) + window.location.href = "/login" + }, []) + + return ( +
+ + + {viewMode === "channels" && ( + <> + + {activeChannel ? ( + + ) : isLoadingChannels ? ( +
+
Loading channels...
+
+ ) : ( +
+
No channels found
+
+ )} + + )} + {viewMode === "direct-messages" && ( + setPendingDMMessage(null)} + /> + )} + {viewMode === "settings" && ( + + )} +
+ ) +} From 3a68f9857c3cccbe63171d20c7541ec11321b819 Mon Sep 17 00:00:00 2001 From: Ilia Date: Fri, 20 Feb 2026 22:27:29 -0600 Subject: [PATCH 03/37] fix: keep humans list stable across page reload --- frontend/components/home-page-client.tsx | 20 +++++++++-- frontend/lib/humans.ts | 46 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/components/home-page-client.tsx b/frontend/components/home-page-client.tsx index e526671d..36c244d6 100644 --- a/frontend/components/home-page-client.tsx +++ b/frontend/components/home-page-client.tsx @@ -7,7 +7,12 @@ import { ChannelSidebar } from "@/components/channel-sidebar" import { ChannelArea } from "@/components/channel-area" import { DirectMessagesView } from "@/components/direct-messages-view" import { SettingsView, getDisplayNameFromStorage } from "@/components/settings/settings-view" -import type { HumanMember } from "@/lib/humans" +import { + clearCachedHumans, + getCachedHumans, + setCachedHumans, + type HumanMember, +} from "@/lib/humans" interface HomePageClientProps { initialHumans: HumanMember[] @@ -19,7 +24,9 @@ export default function HomePageClient({ initialHumans }: HomePageClientProps) { const [isLoadingAgents, setIsLoadingAgents] = useState(true) const [channels, setChannels] = useState([]) const [isLoadingChannels, setIsLoadingChannels] = useState(true) - const [humans, setHumans] = useState(initialHumans) + const [humans, setHumans] = useState(() => + initialHumans.length > 0 ? initialHumans : getCachedHumans() + ) const [activeChannelId, setActiveChannelId] = useState("") const [activeDMId, setActiveDMId] = useState(null) const [pendingDMMessage, setPendingDMMessage] = useState(null) @@ -33,12 +40,19 @@ export default function HomePageClient({ initialHumans }: HomePageClientProps) { if (viewMode !== "settings") setDisplayName(getDisplayNameFromStorage()) }, [viewMode]) + useEffect(() => { + if (humans.length > 0) { + setCachedHumans(humans) + } + }, [humans]) + useEffect(() => { let isCancelled = false async function ensureAuthenticated() { const response = await fetch("/api/auth/me") if (!response.ok && !isCancelled) { + clearCachedHumans() await fetch("/api/auth/logout", { method: "POST" }) window.location.href = "/login" } @@ -106,6 +120,7 @@ export default function HomePageClient({ initialHumans }: HomePageClientProps) { const users: HumanMember[] = await response.json() if (!isCancelled) { setHumans(users) + setCachedHumans(users) } } catch (error) { console.error("Failed to load users from backend:", error) @@ -229,6 +244,7 @@ export default function HomePageClient({ initialHumans }: HomePageClientProps) { }, []) const handleLogout = useCallback(async () => { + clearCachedHumans() await fetch("/api/auth/logout", { method: "POST" }) window.location.href = "/login" }, []) diff --git a/frontend/lib/humans.ts b/frontend/lib/humans.ts index cc8e813f..947c58e1 100644 --- a/frontend/lib/humans.ts +++ b/frontend/lib/humans.ts @@ -10,6 +10,7 @@ export interface HumanMember { } const HUMAN_ID_PREFIX = "human:" +const HUMANS_CACHE_KEY = "aoc_humans_cache_v1" export function toHumanCardId(userId: number): string { return `${HUMAN_ID_PREFIX}${userId}` @@ -18,3 +19,48 @@ export function toHumanCardId(userId: number): string { export function isHumanCardId(id: string): boolean { return id.startsWith(HUMAN_ID_PREFIX) } + +export function getCachedHumans(): HumanMember[] { + if (typeof window === "undefined") return [] + + try { + const raw = window.sessionStorage.getItem(HUMANS_CACHE_KEY) + if (!raw) return [] + + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + + return parsed.filter( + (item): item is HumanMember => + item && + typeof item.id === "string" && + typeof item.userId === "number" && + typeof item.name === "string" && + typeof item.email === "string" && + (item.status === "Online" || item.status === "Offline") && + typeof item.isCurrentUser === "boolean" + ) + } catch { + return [] + } +} + +export function setCachedHumans(humans: HumanMember[]): void { + if (typeof window === "undefined") return + + try { + window.sessionStorage.setItem(HUMANS_CACHE_KEY, JSON.stringify(humans)) + } catch { + // Ignore storage failures. + } +} + +export function clearCachedHumans(): void { + if (typeof window === "undefined") return + + try { + window.sessionStorage.removeItem(HUMANS_CACHE_KEY) + } catch { + // Ignore storage failures. + } +} From 9a2ab83287c127451f7950587bf4803f9f15b219 Mon Sep 17 00:00:00 2001 From: Ilia Date: Fri, 20 Feb 2026 23:29:46 -0600 Subject: [PATCH 04/37] feat: add brevo email verification flow for signup --- .github/workflows/deploy.yml | 10 +- backend/.env.example | 8 + backend/docker-compose.yml | 8 + .../Api/Email/BrevoRegistrationEmailSender.cs | 78 +++++ .../src/Api/Email/IRegistrationEmailSender.cs | 10 + backend/src/Api/Endpoints/AuthEndpoints.cs | 257 +++++++++++++- .../Dtos/Auth/RegisterChallengeDto.cs | 6 + .../Auth/ResendRegistrationCodeRequestDto.cs | 11 + .../Auth/VerifyRegistrationCodeRequestDto.cs | 15 + .../Extensions/ServiceCollectionExtensions.cs | 51 +++ backend/src/Api/Program.cs | 1 + backend/src/Api/Settings/BrevoSettings.cs | 9 + .../Api/Settings/EmailVerificationSettings.cs | 9 + backend/src/Api/appsettings.json | 12 + .../src/Domain/Users/PendingRegistration.cs | 52 +++ .../Infrastructure.Db/AzureOpsCrewContext.cs | 3 + ...dingRegistrationEntityTypeConfiguration.cs | 55 +++ .../M008_AddPendingRegistrationTable.cs | 33 ++ .../app/api/auth/register/resend/route.ts | 41 +++ frontend/app/api/auth/register/route.ts | 39 ++- .../app/api/auth/register/verify/route.ts | 63 ++++ frontend/app/signup/page.tsx | 316 ++++++++++++++---- 22 files changed, 991 insertions(+), 96 deletions(-) create mode 100644 backend/src/Api/Email/BrevoRegistrationEmailSender.cs create mode 100644 backend/src/Api/Email/IRegistrationEmailSender.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/RegisterChallengeDto.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/ResendRegistrationCodeRequestDto.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/VerifyRegistrationCodeRequestDto.cs create mode 100644 backend/src/Api/Settings/BrevoSettings.cs create mode 100644 backend/src/Api/Settings/EmailVerificationSettings.cs create mode 100644 backend/src/Domain/Users/PendingRegistration.cs create mode 100644 backend/src/Infrastructure.Db/EntityTypes/Sqlite/PendingRegistrationEntityTypeConfiguration.cs create mode 100644 backend/src/Infrastructure.Db/Migrations/M008_AddPendingRegistrationTable.cs create mode 100644 frontend/app/api/auth/register/resend/route.ts create mode 100644 frontend/app/api/auth/register/verify/route.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9db83192..6bbe8ff8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -118,7 +118,15 @@ jobs: --set-env-vars \ Jwt__Issuer=${{ secrets.JWT_ISSUER }} \ Jwt__Audience=${{ secrets.JWT_AUDIENCE }} \ - Jwt__SigningKey=${{ secrets.JWT_SIGNING_KEY }} + Jwt__SigningKey=${{ secrets.JWT_SIGNING_KEY }} \ + EmailVerification__CodeLength=6 \ + EmailVerification__CodeTtlMinutes=10 \ + EmailVerification__ResendCooldownSeconds=30 \ + EmailVerification__MaxVerificationAttempts=5 \ + Brevo__ApiBaseUrl=https://api.brevo.com \ + Brevo__ApiKey=${{ secrets.BREVO_API_KEY }} \ + Brevo__SenderEmail=${{ secrets.BREVO_SENDER_EMAIL }} \ + Brevo__SenderName="${{ secrets.BREVO_SENDER_NAME }}" echo "✅ Backend deployed" - name: Deploy Frontend diff --git a/backend/.env.example b/backend/.env.example index db3d699b..fc492500 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,4 +3,12 @@ AZURE_OPENAI_API_ENDPOINT= JWT_SIGNING_KEY=ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars! JWT_ISSUER=AzureOpsCrew JWT_AUDIENCE=AzureOpsCrewFrontend +EMAIL_VERIFICATION_CODE_LENGTH=6 +EMAIL_VERIFICATION_CODE_TTL_MINUTES=10 +EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=30 +EMAIL_VERIFICATION_MAX_ATTEMPTS=5 +BREVO_API_KEY= +BREVO_API_BASE_URL=https://api.brevo.com +BREVO_SENDER_EMAIL=azureopscrew@aoc-app.com +BREVO_SENDER_NAME=Azure Ops Crew SEEDING_ENABLED=false diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 33067d73..35e18488 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -14,6 +14,14 @@ services: - Jwt__Issuer=${JWT_ISSUER:-AzureOpsCrew} - Jwt__Audience=${JWT_AUDIENCE:-AzureOpsCrewFrontend} - Jwt__SigningKey=${JWT_SIGNING_KEY:-ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars!} + - EmailVerification__CodeLength=${EMAIL_VERIFICATION_CODE_LENGTH:-6} + - EmailVerification__CodeTtlMinutes=${EMAIL_VERIFICATION_CODE_TTL_MINUTES:-10} + - EmailVerification__ResendCooldownSeconds=${EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS:-30} + - EmailVerification__MaxVerificationAttempts=${EMAIL_VERIFICATION_MAX_ATTEMPTS:-5} + - Brevo__ApiBaseUrl=${BREVO_API_BASE_URL:-https://api.brevo.com} + - Brevo__ApiKey=${BREVO_API_KEY:-} + - Brevo__SenderEmail=${BREVO_SENDER_EMAIL:-azureopscrew@aoc-app.com} + - Brevo__SenderName=${BREVO_SENDER_NAME:-Azure Ops Crew} - Seeding__IsEnabled=${SEEDING_ENABLED:-false} - Seeding__AzureFoundrySeed__ApiEndpoint=${AZURE_OPENAI_API_ENDPOINT:-} - Seeding__AzureFoundrySeed__Key=${AZURE_OPENAI_API_KEY:-} diff --git a/backend/src/Api/Email/BrevoRegistrationEmailSender.cs b/backend/src/Api/Email/BrevoRegistrationEmailSender.cs new file mode 100644 index 00000000..9f8b6f70 --- /dev/null +++ b/backend/src/Api/Email/BrevoRegistrationEmailSender.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Json; +using AzureOpsCrew.Api.Settings; +using Microsoft.Extensions.Options; + +namespace AzureOpsCrew.Api.Email; + +public sealed class BrevoRegistrationEmailSender : IRegistrationEmailSender +{ + private readonly HttpClient _httpClient; + private readonly BrevoSettings _settings; + private readonly ILogger _logger; + + public BrevoRegistrationEmailSender( + HttpClient httpClient, + IOptions settings, + ILogger logger) + { + _httpClient = httpClient; + _settings = settings.Value; + _logger = logger; + } + + public async Task SendRegistrationCodeAsync( + string recipientEmail, + string verificationCode, + DateTime expiresAtUtc, + CancellationToken cancellationToken) + { + var subject = "Your Azure Ops Crew security code"; + var expiresAt = expiresAtUtc.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"); + var htmlContent = + $""" + + +

Your Azure Ops Crew verification code is:

+

{verificationCode}

+

This code expires at {expiresAt}.

+

If you did not request this, you can safely ignore this email.

+ + + """; + var textContent = + $"Your Azure Ops Crew verification code is: {verificationCode}. This code expires at {expiresAt}."; + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v3/smtp/email") + { + Content = JsonContent.Create(new BrevoSendEmailRequest( + Sender: new BrevoContact(_settings.SenderEmail, _settings.SenderName), + To: [new BrevoContact(recipientEmail, null)], + Subject: subject, + HtmlContent: htmlContent, + TextContent: textContent)) + }; + + request.Headers.Add("api-key", _settings.ApiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + return; + + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "Brevo email send failed. StatusCode={StatusCode}. Response={Response}", + (int)response.StatusCode, + responseBody); + + throw new InvalidOperationException("Unable to send verification email."); + } + + private sealed record BrevoContact(string Email, string? Name); + + private sealed record BrevoSendEmailRequest( + BrevoContact Sender, + BrevoContact[] To, + string Subject, + string HtmlContent, + string TextContent); +} diff --git a/backend/src/Api/Email/IRegistrationEmailSender.cs b/backend/src/Api/Email/IRegistrationEmailSender.cs new file mode 100644 index 00000000..de0bde98 --- /dev/null +++ b/backend/src/Api/Email/IRegistrationEmailSender.cs @@ -0,0 +1,10 @@ +namespace AzureOpsCrew.Api.Email; + +public interface IRegistrationEmailSender +{ + Task SendRegistrationCodeAsync( + string recipientEmail, + string verificationCode, + DateTime expiresAtUtc, + CancellationToken cancellationToken); +} diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs index f6cbfafa..eaf7ab2f 100644 --- a/backend/src/Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -1,10 +1,14 @@ using AzureOpsCrew.Api.Auth; +using AzureOpsCrew.Api.Email; using AzureOpsCrew.Api.Endpoints.Dtos.Auth; using AzureOpsCrew.Api.Endpoints.Filters; +using AzureOpsCrew.Api.Settings; using AzureOpsCrew.Domain.Users; using AzureOpsCrew.Infrastructure.Db; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; namespace AzureOpsCrew.Api.Endpoints; @@ -18,11 +22,15 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapPost("/register", async ( RegisterRequestDto body, AzureOpsCrewContext context, - IPasswordHasher passwordHasher, - JwtTokenService jwtTokenService, + IPasswordHasher pendingRegistrationHasher, + IRegistrationEmailSender registrationEmailSender, + IOptions emailVerificationOptions, CancellationToken cancellationToken) => { + var settings = emailVerificationOptions.Value; + var now = DateTime.UtcNow; var normalizedEmail = NormalizeEmail(body.Email); + var email = body.Email.Trim(); var exists = await context.Users .AnyAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); @@ -30,26 +38,237 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder routeBuilder) if (exists) return Results.Conflict(new { error = "Email is already registered." }); + var pendingRegistration = await context.PendingRegistrations + .SingleOrDefaultAsync(p => p.NormalizedEmail == normalizedEmail, cancellationToken); + + if (pendingRegistration is not null) + { + var remainingCooldown = GetRemainingCooldownSeconds( + now, + pendingRegistration.VerificationCodeSentAt, + settings.ResendCooldownSeconds); + + if (remainingCooldown > 0) + { + return Results.Json( + new + { + error = $"Please wait {remainingCooldown} seconds before requesting another code.", + retryAfterSeconds = remainingCooldown + }, + statusCode: StatusCodes.Status429TooManyRequests); + } + } + + if (pendingRegistration is null) + { + pendingRegistration = new PendingRegistration(email, normalizedEmail); + context.PendingRegistrations.Add(pendingRegistration); + } + var displayName = string.IsNullOrWhiteSpace(body.DisplayName) - ? body.Email.Trim() + ? email : body.DisplayName.Trim(); - var user = new User( - email: body.Email.Trim(), - normalizedEmail: normalizedEmail, - passwordHash: string.Empty, - displayName: displayName); + var verificationCode = GenerateVerificationCode(settings.CodeLength); + var passwordHash = pendingRegistrationHasher.HashPassword(pendingRegistration, body.Password); + var verificationCodeHash = pendingRegistrationHasher.HashPassword(pendingRegistration, verificationCode); + var expiresAtUtc = now.AddMinutes(settings.CodeTtlMinutes); - var hash = passwordHasher.HashPassword(user, body.Password); - user.UpdatePasswordHash(hash); + pendingRegistration.Refresh( + email, + displayName, + passwordHash, + verificationCodeHash, + expiresAtUtc, + now); + + await context.SaveChangesAsync(cancellationToken); + + try + { + await registrationEmailSender.SendRegistrationCodeAsync( + email, + verificationCode, + expiresAtUtc, + cancellationToken); + } + catch (InvalidOperationException) + { + return Results.Json( + new { error = "Unable to send verification email. Please try again." }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + return Results.Ok( + new RegisterChallengeDto( + "Verification code sent. Check your email to continue.", + expiresAtUtc, + settings.ResendCooldownSeconds)); + }) + .AddEndpointFilter>() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status429TooManyRequests) + .Produces(StatusCodes.Status503ServiceUnavailable) + .Produces(StatusCodes.Status409Conflict) + .AllowAnonymous(); + + group.MapPost("/register/resend", async ( + ResendRegistrationCodeRequestDto body, + AzureOpsCrewContext context, + IPasswordHasher pendingRegistrationHasher, + IRegistrationEmailSender registrationEmailSender, + IOptions emailVerificationOptions, + CancellationToken cancellationToken) => + { + var settings = emailVerificationOptions.Value; + var now = DateTime.UtcNow; + var normalizedEmail = NormalizeEmail(body.Email); + + var pendingRegistration = await context.PendingRegistrations + .SingleOrDefaultAsync(p => p.NormalizedEmail == normalizedEmail, cancellationToken); + + if (pendingRegistration is null) + return Results.BadRequest(new { error = "Registration request not found. Start sign up again." }); + + var remainingCooldown = GetRemainingCooldownSeconds( + now, + pendingRegistration.VerificationCodeSentAt, + settings.ResendCooldownSeconds); + + if (remainingCooldown > 0) + { + return Results.Json( + new + { + error = $"Please wait {remainingCooldown} seconds before requesting another code.", + retryAfterSeconds = remainingCooldown + }, + statusCode: StatusCodes.Status429TooManyRequests); + } + + var verificationCode = GenerateVerificationCode(settings.CodeLength); + var verificationCodeHash = pendingRegistrationHasher.HashPassword(pendingRegistration, verificationCode); + var expiresAtUtc = now.AddMinutes(settings.CodeTtlMinutes); + + pendingRegistration.Refresh( + pendingRegistration.Email, + pendingRegistration.DisplayName, + pendingRegistration.PasswordHash, + verificationCodeHash, + expiresAtUtc, + now); + + await context.SaveChangesAsync(cancellationToken); + + try + { + await registrationEmailSender.SendRegistrationCodeAsync( + pendingRegistration.Email, + verificationCode, + expiresAtUtc, + cancellationToken); + } + catch (InvalidOperationException) + { + return Results.Json( + new { error = "Unable to send verification email. Please try again." }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + return Results.Ok( + new RegisterChallengeDto( + "A new verification code has been sent.", + expiresAtUtc, + settings.ResendCooldownSeconds)); + }) + .AddEndpointFilter>() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status429TooManyRequests) + .Produces(StatusCodes.Status503ServiceUnavailable) + .AllowAnonymous(); + + group.MapPost("/register/verify", async ( + VerifyRegistrationCodeRequestDto body, + AzureOpsCrewContext context, + IPasswordHasher pendingRegistrationHasher, + JwtTokenService jwtTokenService, + IOptions emailVerificationOptions, + CancellationToken cancellationToken) => + { + var settings = emailVerificationOptions.Value; + var now = DateTime.UtcNow; + var normalizedEmail = NormalizeEmail(body.Email); + + var pendingRegistration = await context.PendingRegistrations + .SingleOrDefaultAsync(p => p.NormalizedEmail == normalizedEmail, cancellationToken); + + if (pendingRegistration is null) + return Results.BadRequest(new { error = "Registration request not found. Start sign up again." }); + + if (pendingRegistration.VerificationCodeExpiresAt < now) + { + context.PendingRegistrations.Remove(pendingRegistration); + await context.SaveChangesAsync(cancellationToken); + return Results.BadRequest(new { error = "Verification code expired. Request a new one." }); + } + + if (pendingRegistration.VerificationAttempts >= settings.MaxVerificationAttempts) + { + context.PendingRegistrations.Remove(pendingRegistration); + await context.SaveChangesAsync(cancellationToken); + return Results.BadRequest(new { error = "Too many invalid attempts. Start sign up again." }); + } + + var code = body.Code.Trim(); + var verificationResult = pendingRegistrationHasher.VerifyHashedPassword( + pendingRegistration, + pendingRegistration.VerificationCodeHash, + code); + + if (verificationResult == PasswordVerificationResult.Failed) + { + pendingRegistration.IncrementFailedAttempt(); + var attemptsLeft = settings.MaxVerificationAttempts - pendingRegistration.VerificationAttempts; + + if (attemptsLeft <= 0) + { + context.PendingRegistrations.Remove(pendingRegistration); + await context.SaveChangesAsync(cancellationToken); + return Results.BadRequest(new { error = "Too many invalid attempts. Start sign up again." }); + } + + await context.SaveChangesAsync(cancellationToken); + return Results.BadRequest(new { error = $"Invalid verification code. {attemptsLeft} attempt(s) left." }); + } + + var exists = await context.Users + .AnyAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); + + if (exists) + { + context.PendingRegistrations.Remove(pendingRegistration); + await context.SaveChangesAsync(cancellationToken); + return Results.Conflict(new { error = "Email is already registered." }); + } + + var user = new User( + email: pendingRegistration.Email, + normalizedEmail: pendingRegistration.NormalizedEmail, + passwordHash: pendingRegistration.PasswordHash, + displayName: pendingRegistration.DisplayName); + user.MarkLogin(); context.Users.Add(user); + context.PendingRegistrations.Remove(pendingRegistration); await context.SaveChangesAsync(cancellationToken); var token = jwtTokenService.CreateToken(user); return Results.Ok(ToAuthResponse(user, token)); }) - .AddEndpointFilter>() + .AddEndpointFilter>() .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status409Conflict) @@ -121,6 +340,22 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder routeBuilder) private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant(); + private static int GetRemainingCooldownSeconds( + DateTime nowUtc, + DateTime lastSentAtUtc, + int resendCooldownSeconds) + { + var secondsSinceLastSend = (int)(nowUtc - lastSentAtUtc).TotalSeconds; + return Math.Max(0, resendCooldownSeconds - secondsSinceLastSend); + } + + private static string GenerateVerificationCode(int codeLength) + { + var maxExclusive = (int)Math.Pow(10, codeLength); + var value = RandomNumberGenerator.GetInt32(0, maxExclusive); + return value.ToString($"D{codeLength}"); + } + private static AuthResponseDto ToAuthResponse(User user, AuthTokenResult token) { return new AuthResponseDto( diff --git a/backend/src/Api/Endpoints/Dtos/Auth/RegisterChallengeDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/RegisterChallengeDto.cs new file mode 100644 index 00000000..e1cc1ffe --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/RegisterChallengeDto.cs @@ -0,0 +1,6 @@ +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed record RegisterChallengeDto( + string Message, + DateTime ExpiresAtUtc, + int ResendAvailableInSeconds); diff --git a/backend/src/Api/Endpoints/Dtos/Auth/ResendRegistrationCodeRequestDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/ResendRegistrationCodeRequestDto.cs new file mode 100644 index 00000000..92e423a8 --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/ResendRegistrationCodeRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed class ResendRegistrationCodeRequestDto +{ + [Required] + [EmailAddress] + [StringLength(320, MinimumLength = 3)] + public string Email { get; set; } = string.Empty; +} diff --git a/backend/src/Api/Endpoints/Dtos/Auth/VerifyRegistrationCodeRequestDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/VerifyRegistrationCodeRequestDto.cs new file mode 100644 index 00000000..43a175d9 --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/VerifyRegistrationCodeRequestDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed class VerifyRegistrationCodeRequestDto +{ + [Required] + [EmailAddress] + [StringLength(320, MinimumLength = 3)] + public string Email { get; set; } = string.Empty; + + [Required] + [RegularExpression(@"^\d{4,8}$")] + public string Code { get; set; } = string.Empty; +} diff --git a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs index 35af1214..413be042 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using System.Text; using AzureOpsCrew.Api.Auth; +using AzureOpsCrew.Api.Email; using AzureOpsCrew.Api.Settings; using AzureOpsCrew.Domain.Providers; using AzureOpsCrew.Domain.Users; @@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; namespace AzureOpsCrew.Api.Extensions; @@ -158,4 +160,53 @@ public static void AddJwtAuthentication(this IServiceCollection services, IConfi services.AddSingleton(); services.AddScoped, PasswordHasher>(); } + + public static void AddEmailVerification(this IServiceCollection services, IConfiguration configuration) + { + var brevoSettings = configuration.GetSection("Brevo").Get() ?? new BrevoSettings(); + var emailVerificationSettings = configuration.GetSection("EmailVerification").Get() + ?? new EmailVerificationSettings(); + + if (string.IsNullOrWhiteSpace(brevoSettings.ApiBaseUrl)) + throw new InvalidOperationException("Brevo__ApiBaseUrl is required."); + + if (string.IsNullOrWhiteSpace(brevoSettings.ApiKey)) + throw new InvalidOperationException("Brevo__ApiKey is required."); + + if (brevoSettings.ApiKey.Contains("CHANGEME", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("A real Brevo API key must be configured."); + + if (string.IsNullOrWhiteSpace(brevoSettings.SenderEmail)) + throw new InvalidOperationException("Brevo__SenderEmail is required."); + + if (string.IsNullOrWhiteSpace(brevoSettings.SenderName)) + throw new InvalidOperationException("Brevo__SenderName is required."); + + if (emailVerificationSettings.CodeLength is < 4 or > 8) + throw new InvalidOperationException("EmailVerification__CodeLength must be between 4 and 8."); + + if (emailVerificationSettings.CodeTtlMinutes <= 0) + throw new InvalidOperationException("EmailVerification__CodeTtlMinutes must be greater than zero."); + + if (emailVerificationSettings.ResendCooldownSeconds < 0) + throw new InvalidOperationException("EmailVerification__ResendCooldownSeconds must be zero or greater."); + + if (emailVerificationSettings.MaxVerificationAttempts <= 0) + throw new InvalidOperationException("EmailVerification__MaxVerificationAttempts must be greater than zero."); + + services.Configure(configuration.GetSection("Brevo")); + services.AddOptions(); + + services.Configure(configuration.GetSection("EmailVerification")); + services.AddOptions(); + + services.AddHttpClient((sp, client) => + { + var settings = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(settings.ApiBaseUrl); + client.Timeout = TimeSpan.FromSeconds(15); + }); + + services.AddScoped, PasswordHasher>(); + } } diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index 98ccd489..428812e6 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddDatabase(builder.Configuration); builder.Services.AddProviderFacades(); builder.Services.AddJwtAuthentication(builder.Configuration, builder.Environment); + builder.Services.AddEmailVerification(builder.Configuration); // Configure AG-UI builder.Services.AddHttpClient(); diff --git a/backend/src/Api/Settings/BrevoSettings.cs b/backend/src/Api/Settings/BrevoSettings.cs new file mode 100644 index 00000000..ab2c23af --- /dev/null +++ b/backend/src/Api/Settings/BrevoSettings.cs @@ -0,0 +1,9 @@ +namespace AzureOpsCrew.Api.Settings; + +public sealed class BrevoSettings +{ + public string ApiBaseUrl { get; set; } = "https://api.brevo.com"; + public string ApiKey { get; set; } = string.Empty; + public string SenderEmail { get; set; } = "azureopscrew@aoc-app.com"; + public string SenderName { get; set; } = "Azure Ops Crew"; +} diff --git a/backend/src/Api/Settings/EmailVerificationSettings.cs b/backend/src/Api/Settings/EmailVerificationSettings.cs new file mode 100644 index 00000000..6de6d4ba --- /dev/null +++ b/backend/src/Api/Settings/EmailVerificationSettings.cs @@ -0,0 +1,9 @@ +namespace AzureOpsCrew.Api.Settings; + +public sealed class EmailVerificationSettings +{ + public int CodeLength { get; set; } = 6; + public int CodeTtlMinutes { get; set; } = 10; + public int ResendCooldownSeconds { get; set; } = 30; + public int MaxVerificationAttempts { get; set; } = 5; +} diff --git a/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index e7fb7ff6..201e4fc8 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -23,6 +23,18 @@ "SigningKey": "ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars!", "AccessTokenMinutes": 480 }, + "EmailVerification": { + "CodeLength": 6, + "CodeTtlMinutes": 10, + "ResendCooldownSeconds": 30, + "MaxVerificationAttempts": 5 + }, + "Brevo": { + "ApiBaseUrl": "https://api.brevo.com", + "ApiKey": "CHANGEME", + "SenderEmail": "azureopscrew@aoc-app.com", + "SenderName": "Azure Ops Crew" + }, "Seeding": { "IsEnabled": false, "AzureFoundrySeed": { diff --git a/backend/src/Domain/Users/PendingRegistration.cs b/backend/src/Domain/Users/PendingRegistration.cs new file mode 100644 index 00000000..b319e531 --- /dev/null +++ b/backend/src/Domain/Users/PendingRegistration.cs @@ -0,0 +1,52 @@ +#pragma warning disable CS8618 + +namespace AzureOpsCrew.Domain.Users; + +public sealed class PendingRegistration +{ + private PendingRegistration() + { + } + + public PendingRegistration(string email, string normalizedEmail) + { + Email = email; + NormalizedEmail = normalizedEmail; + } + + public int Id { get; private set; } + public string Email { get; private set; } + public string NormalizedEmail { get; private set; } + public string DisplayName { get; private set; } = string.Empty; + public string PasswordHash { get; private set; } = string.Empty; + public string VerificationCodeHash { get; private set; } = string.Empty; + public DateTime VerificationCodeExpiresAt { get; private set; } + public DateTime VerificationCodeSentAt { get; private set; } + public int VerificationAttempts { get; private set; } + public DateTime DateCreated { get; private set; } = DateTime.UtcNow; + public DateTime? DateModified { get; private set; } + + public void Refresh( + string email, + string displayName, + string passwordHash, + string verificationCodeHash, + DateTime codeExpiresAtUtc, + DateTime codeSentAtUtc) + { + Email = email; + DisplayName = displayName; + PasswordHash = passwordHash; + VerificationCodeHash = verificationCodeHash; + VerificationCodeExpiresAt = codeExpiresAtUtc; + VerificationCodeSentAt = codeSentAtUtc; + VerificationAttempts = 0; + DateModified = DateTime.UtcNow; + } + + public void IncrementFailedAttempt() + { + VerificationAttempts += 1; + DateModified = DateTime.UtcNow; + } +} diff --git a/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs b/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs index d59db2f6..b0396d30 100644 --- a/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs +++ b/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs @@ -5,6 +5,7 @@ using AgentConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.AgentEntityTypeConfiguration; using ChannelConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.ChannelEntityTypeConfiguration; using AiProviderConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.ProviderEntityTypeConfiguration; +using PendingRegistrationConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.PendingRegistrationEntityTypeConfiguration; using UserConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.UserEntityTypeConfiguration; using AiProvider = AzureOpsCrew.Domain.Providers.Provider; @@ -21,6 +22,7 @@ public AzureOpsCrewContext(DbContextOptions options) public DbSet Channels => Set(); public DbSet Providers => Set(); public DbSet Users => Set(); + public DbSet PendingRegistrations => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -28,5 +30,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new ChannelConfig()); modelBuilder.ApplyConfiguration(new AiProviderConfig()); modelBuilder.ApplyConfiguration(new UserConfig()); + modelBuilder.ApplyConfiguration(new PendingRegistrationConfig()); } } diff --git a/backend/src/Infrastructure.Db/EntityTypes/Sqlite/PendingRegistrationEntityTypeConfiguration.cs b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/PendingRegistrationEntityTypeConfiguration.cs new file mode 100644 index 00000000..9a5b1e7a --- /dev/null +++ b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/PendingRegistrationEntityTypeConfiguration.cs @@ -0,0 +1,55 @@ +using AzureOpsCrew.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite; + +public sealed class PendingRegistrationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PendingRegistration"); + + builder.HasKey(x => x.Id); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + + builder.Property(x => x.Email) + .IsRequired() + .HasMaxLength(320); + + builder.Property(x => x.NormalizedEmail) + .IsRequired() + .HasMaxLength(320); + + builder.HasIndex(x => x.NormalizedEmail) + .IsUnique(); + + builder.Property(x => x.DisplayName) + .IsRequired() + .HasMaxLength(120); + + builder.Property(x => x.PasswordHash) + .IsRequired() + .HasMaxLength(512); + + builder.Property(x => x.VerificationCodeHash) + .IsRequired() + .HasMaxLength(512); + + builder.Property(x => x.VerificationCodeExpiresAt) + .IsRequired(); + + builder.Property(x => x.VerificationCodeSentAt) + .IsRequired(); + + builder.Property(x => x.VerificationAttempts) + .IsRequired() + .HasDefaultValue(0); + + builder.Property(x => x.DateCreated) + .IsRequired(); + + builder.Property(x => x.DateModified); + } +} diff --git a/backend/src/Infrastructure.Db/Migrations/M008_AddPendingRegistrationTable.cs b/backend/src/Infrastructure.Db/Migrations/M008_AddPendingRegistrationTable.cs new file mode 100644 index 00000000..1598fc9d --- /dev/null +++ b/backend/src/Infrastructure.Db/Migrations/M008_AddPendingRegistrationTable.cs @@ -0,0 +1,33 @@ +using FluentMigrator; + +namespace AzureOpsCrew.Infrastructure.Db.Migrations; + +[Migration(2026_02_22_09_00_00, "Add PendingRegistration table for email verification")] +public class M008_AddPendingRegistrationTable : Migration +{ + public override void Up() + { + Create.Table("PendingRegistration") + .WithColumn("Id").AsInt32().PrimaryKey().Identity() + .WithColumn("Email").AsString(320).NotNullable() + .WithColumn("NormalizedEmail").AsString(320).NotNullable() + .WithColumn("DisplayName").AsString(120).NotNullable() + .WithColumn("PasswordHash").AsString(512).NotNullable() + .WithColumn("VerificationCodeHash").AsString(512).NotNullable() + .WithColumn("VerificationCodeExpiresAt").AsDateTime().NotNullable() + .WithColumn("VerificationCodeSentAt").AsDateTime().NotNullable() + .WithColumn("VerificationAttempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("DateCreated").AsDateTime().NotNullable() + .WithColumn("DateModified").AsDateTime().Nullable(); + + Create.Index("IX_PendingRegistration_NormalizedEmail") + .OnTable("PendingRegistration") + .OnColumn("NormalizedEmail") + .Unique(); + } + + public override void Down() + { + Delete.Table("PendingRegistration"); + } +} diff --git a/frontend/app/api/auth/register/resend/route.ts b/frontend/app/api/auth/register/resend/route.ts new file mode 100644 index 00000000..234a4588 --- /dev/null +++ b/frontend/app/api/auth/register/resend/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +function extractErrorMessage(data: any, fallback: string) { + if (typeof data?.error === "string") return data.error + if (typeof data?.Error === "string") return data.Error + if (data?.errors && typeof data.errors === "object") { + const first = Object.values(data.errors)[0] + if (typeof first === "string") return first + } + if (data?.Errors && typeof data.Errors === "object") { + const first = Object.values(data.Errors)[0] + if (typeof first === "string") return first + } + return fallback +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const response = await fetch(`${BACKEND_API_URL}/api/auth/register/resend`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + return NextResponse.json( + { error: extractErrorMessage(data, "Unable to resend verification code") }, + { status: response.status } + ) + } + + return NextResponse.json(data) + } catch (error) { + console.error("Error resending verification code:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/auth/register/route.ts b/frontend/app/api/auth/register/route.ts index 2345a753..7bfcf5ff 100644 --- a/frontend/app/api/auth/register/route.ts +++ b/frontend/app/api/auth/register/route.ts @@ -1,16 +1,25 @@ import { NextRequest, NextResponse } from "next/server" -import { ACCESS_TOKEN_COOKIE_NAME, getAuthCookieOptions } from "@/lib/server/auth" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -interface BackendAuthResponse { - accessToken: string +interface BackendRegisterChallengeResponse { + message: string expiresAtUtc: string - user: { - id: number - email: string - displayName: string + resendAvailableInSeconds: number +} + +function extractErrorMessage(data: any, fallback: string) { + if (typeof data?.error === "string") return data.error + if (typeof data?.Error === "string") return data.Error + if (data?.errors && typeof data.errors === "object") { + const first = Object.values(data.errors)[0] + if (typeof first === "string") return first + } + if (data?.Errors && typeof data.Errors === "object") { + const first = Object.values(data.Errors)[0] + if (typeof first === "string") return first } + return fallback } export async function POST(req: NextRequest) { @@ -25,23 +34,17 @@ export async function POST(req: NextRequest) { const data = await response.json().catch(() => ({})) if (!response.ok) { return NextResponse.json( - data?.error ? { error: data.error } : { error: "Registration failed" }, + { error: extractErrorMessage(data, "Unable to start registration") }, { status: response.status } ) } - const authData = data as BackendAuthResponse - if (!authData.accessToken) { - return NextResponse.json({ error: "Missing access token" }, { status: 502 }) + const challenge = data as BackendRegisterChallengeResponse + if (!challenge.expiresAtUtc) { + return NextResponse.json({ error: "Invalid verification response" }, { status: 502 }) } - const nextResponse = NextResponse.json({ - expiresAtUtc: authData.expiresAtUtc, - user: authData.user, - }) - nextResponse.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) - - return nextResponse + return NextResponse.json(challenge) } catch (error) { console.error("Error registering:", error) return NextResponse.json({ error: "Internal server error" }, { status: 500 }) diff --git a/frontend/app/api/auth/register/verify/route.ts b/frontend/app/api/auth/register/verify/route.ts new file mode 100644 index 00000000..ac38c6b8 --- /dev/null +++ b/frontend/app/api/auth/register/verify/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME, getAuthCookieOptions } from "@/lib/server/auth" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +interface BackendAuthResponse { + accessToken: string + expiresAtUtc: string + user: { + id: number + email: string + displayName: string + } +} + +function extractErrorMessage(data: any, fallback: string) { + if (typeof data?.error === "string") return data.error + if (typeof data?.Error === "string") return data.Error + if (data?.errors && typeof data.errors === "object") { + const first = Object.values(data.errors)[0] + if (typeof first === "string") return first + } + if (data?.Errors && typeof data.Errors === "object") { + const first = Object.values(data.Errors)[0] + if (typeof first === "string") return first + } + return fallback +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const response = await fetch(`${BACKEND_API_URL}/api/auth/register/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + return NextResponse.json( + { error: extractErrorMessage(data, "Verification failed") }, + { status: response.status } + ) + } + + const authData = data as BackendAuthResponse + if (!authData.accessToken) { + return NextResponse.json({ error: "Missing access token" }, { status: 502 }) + } + + const nextResponse = NextResponse.json({ + expiresAtUtc: authData.expiresAtUtc, + user: authData.user, + }) + nextResponse.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) + + return nextResponse + } catch (error) { + console.error("Error verifying registration code:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index bed0f706..ed8488ad 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,19 +1,64 @@ "use client" -import { FormEvent, useState } from "react" +import { FormEvent, useEffect, useMemo, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" +type SignupStep = "details" | "verify" + +interface RegisterChallengeResponse { + message: string + expiresAtUtc: string + resendAvailableInSeconds: number +} + +function formatCountdown(totalSeconds: number) { + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, "0")}` +} + export default function SignupPage() { const router = useRouter() + const [step, setStep] = useState("details") + const [displayName, setDisplayName] = useState("") const [email, setEmail] = useState("") const [password, setPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") + + const [verificationCode, setVerificationCode] = useState("") + const [expiresAtUtc, setExpiresAtUtc] = useState(null) + const [resendCooldownSeconds, setResendCooldownSeconds] = useState(0) + const [challengeMessage, setChallengeMessage] = useState(null) const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isResending, setIsResending] = useState(false) + + useEffect(() => { + if (resendCooldownSeconds <= 0) return - async function handleSubmit(event: FormEvent) { + const timer = window.setInterval(() => { + setResendCooldownSeconds((current) => Math.max(0, current - 1)) + }, 1000) + + return () => { + window.clearInterval(timer) + } + }, [resendCooldownSeconds]) + + const expiresInLabel = useMemo(() => { + if (!expiresAtUtc) return null + + const diffMs = new Date(expiresAtUtc).getTime() - Date.now() + if (diffMs <= 0) return "expired" + + const diffMinutes = Math.ceil(diffMs / 60000) + return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"}` + }, [expiresAtUtc, resendCooldownSeconds]) + + async function handleRequestCode(event: FormEvent) { event.preventDefault() setError(null) @@ -36,19 +81,96 @@ export default function SignupPage() { const data = await response.json().catch(() => ({})) if (!response.ok) { - setError(data?.error ?? "Registration failed") + setError(data?.error ?? "Unable to start registration") + return + } + + const challenge = data as RegisterChallengeResponse + setChallengeMessage(challenge.message) + setExpiresAtUtc(challenge.expiresAtUtc) + setResendCooldownSeconds(Math.max(0, challenge.resendAvailableInSeconds ?? 0)) + setVerificationCode("") + setStep("verify") + } catch { + setError("Unable to start registration. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + async function handleVerifyCode(event: FormEvent) { + event.preventDefault() + setError(null) + + const code = verificationCode.trim() + if (!/^\d{4,8}$/.test(code)) { + setError("Enter a valid numeric verification code") + return + } + + setIsSubmitting(true) + try { + const response = await fetch("/api/auth/register/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + code, + }), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + setError(data?.error ?? "Verification failed") return } router.replace("/") router.refresh() } catch { - setError("Unable to register. Please try again.") + setError("Unable to verify code. Please try again.") } finally { setIsSubmitting(false) } } + async function handleResendCode() { + setError(null) + setIsResending(true) + + try { + const response = await fetch("/api/auth/register/resend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + setError(data?.error ?? "Unable to resend verification code") + return + } + + const challenge = data as RegisterChallengeResponse + setChallengeMessage(challenge.message) + setExpiresAtUtc(challenge.expiresAtUtc) + setResendCooldownSeconds(Math.max(0, challenge.resendAvailableInSeconds ?? 0)) + } catch { + setError("Unable to resend verification code. Please try again.") + } finally { + setIsResending(false) + } + } + + function handleChangeEmail() { + setStep("details") + setVerificationCode("") + setChallengeMessage(null) + setExpiresAtUtc(null) + setResendCooldownSeconds(0) + setError(null) + } + return (
@@ -58,71 +180,133 @@ export default function SignupPage() { href="/login" className="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-200 transition hover:bg-slate-800" > - Login + Sign in -
- - - - - - - - - {error &&

{error}

} - - -
+ {step === "details" ? ( +
+ + + + + + + + + {error &&

{error}

} + + +
+ ) : ( +
+
+ {challengeMessage ?? "We sent a verification code to your email."} +
+ Email: {email} +
+ {expiresInLabel && ( +
Code expires in {expiresInLabel}.
+ )} +
+ + + + {error &&

{error}

} + + + +
+ + + +
+
+ )}
) From a87c18a2259b9ed3cbc29c31b7d442950f319206 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sat, 21 Feb 2026 17:33:58 -0600 Subject: [PATCH 05/37] fix: address major PR review findings --- backend/src/Api/Auth/JwtTokenService.cs | 11 ++++++ backend/src/Api/Endpoints/AuthEndpoints.cs | 39 +++++++++++++------ .../Endpoints/Dtos/Users/UserPresenceDto.cs | 1 - backend/src/Api/Endpoints/UsersEndpoints.cs | 1 - frontend/app/api/users/route.ts | 2 - frontend/app/login/page.tsx | 14 ++++++- frontend/app/page.tsx | 2 - .../components/direct-messages-sidebar.tsx | 6 +-- frontend/lib/humans.ts | 2 - frontend/{proxy.ts => middleware.ts} | 4 +- 10 files changed, 55 insertions(+), 27 deletions(-) rename frontend/{proxy.ts => middleware.ts} (90%) diff --git a/backend/src/Api/Auth/JwtTokenService.cs b/backend/src/Api/Auth/JwtTokenService.cs index f4f60c08..c7690a1b 100644 --- a/backend/src/Api/Auth/JwtTokenService.cs +++ b/backend/src/Api/Auth/JwtTokenService.cs @@ -15,8 +15,19 @@ public sealed class JwtTokenService public JwtTokenService(IOptions settings) { + ArgumentNullException.ThrowIfNull(settings); + _settings = settings.Value; + if (string.IsNullOrWhiteSpace(_settings.SigningKey)) + { + throw new ArgumentException("Jwt:SigningKey must be configured.", nameof(settings)); + } + _signingKey = Encoding.UTF8.GetBytes(_settings.SigningKey); + if (_signingKey.Length < 16) + { + throw new ArgumentException("Jwt:SigningKey must be at least 16 bytes.", nameof(settings)); + } } public AuthTokenResult CreateToken(User user) diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs index eaf7ab2f..d11cb2ff 100644 --- a/backend/src/Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -254,19 +254,36 @@ await registrationEmailSender.SendRegistrationCodeAsync( return Results.Conflict(new { error = "Email is already registered." }); } - var user = new User( - email: pendingRegistration.Email, - normalizedEmail: pendingRegistration.NormalizedEmail, - passwordHash: pendingRegistration.PasswordHash, - displayName: pendingRegistration.DisplayName); - user.MarkLogin(); + try + { + var user = new User( + email: pendingRegistration.Email, + normalizedEmail: pendingRegistration.NormalizedEmail, + passwordHash: pendingRegistration.PasswordHash, + displayName: pendingRegistration.DisplayName); + user.MarkLogin(); - context.Users.Add(user); - context.PendingRegistrations.Remove(pendingRegistration); - await context.SaveChangesAsync(cancellationToken); + context.Users.Add(user); + context.PendingRegistrations.Remove(pendingRegistration); + await context.SaveChangesAsync(cancellationToken); - var token = jwtTokenService.CreateToken(user); - return Results.Ok(ToAuthResponse(user, token)); + var token = jwtTokenService.CreateToken(user); + return Results.Ok(ToAuthResponse(user, token)); + } + catch (DbUpdateException) + { + // Save can race with another successful verification for the same email. + context.ChangeTracker.Clear(); + + var emailAlreadyExists = await context.Users + .AsNoTracking() + .AnyAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); + + if (emailAlreadyExists) + return Results.Conflict(new { error = "Email is already registered." }); + + throw; + } }) .AddEndpointFilter>() .Produces(StatusCodes.Status200OK) diff --git a/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs b/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs index b15a3b73..751de343 100644 --- a/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs +++ b/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs @@ -3,7 +3,6 @@ namespace AzureOpsCrew.Api.Endpoints.Dtos.Users; public sealed record UserPresenceDto( int Id, string DisplayName, - string Email, bool IsOnline, bool IsCurrentUser, DateTime? LastSeenAtUtc); diff --git a/backend/src/Api/Endpoints/UsersEndpoints.cs b/backend/src/Api/Endpoints/UsersEndpoints.cs index ba2ece78..23041924 100644 --- a/backend/src/Api/Endpoints/UsersEndpoints.cs +++ b/backend/src/Api/Endpoints/UsersEndpoints.cs @@ -44,7 +44,6 @@ public static void MapUsersEndpoints(this IEndpointRouteBuilder routeBuilder) .Select(u => new UserPresenceDto( u.Id, u.DisplayName, - u.Email, u.LastLoginAt.HasValue && now - u.LastLoginAt.Value <= OnlineWindow, u.Id == currentUserId, u.LastLoginAt)) diff --git a/frontend/app/api/users/route.ts b/frontend/app/api/users/route.ts index 86da4cc4..c08c48d3 100644 --- a/frontend/app/api/users/route.ts +++ b/frontend/app/api/users/route.ts @@ -8,7 +8,6 @@ const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" interface BackendUserPresence { id: number displayName: string - email: string isOnline: boolean isCurrentUser: boolean lastSeenAtUtc: string | null @@ -38,7 +37,6 @@ export async function GET(req: NextRequest) { id: toHumanCardId(user.id), userId: user.id, name: user.displayName, - email: user.email, status: user.isOnline ? "Online" : "Offline", isCurrentUser: user.isCurrentUser, }) diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 2cb0ef9f..c4ddd9e1 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -4,6 +4,18 @@ import { FormEvent, Suspense, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" +function toSafeNextPath(next: string | null): string { + if (!next) { + return "/" + } + + if (!next.startsWith("/") || next.startsWith("//")) { + return "/" + } + + return next +} + function LoginPageContent() { const router = useRouter() const searchParams = useSearchParams() @@ -30,7 +42,7 @@ function LoginPageContent() { return } - const nextPath = searchParams.get("next") || "/" + const nextPath = toSafeNextPath(searchParams.get("next")) router.replace(nextPath) router.refresh() } catch { diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c9c91e84..d1d7cc48 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -8,7 +8,6 @@ const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" interface BackendUserPresence { id: number displayName: string - email: string isOnline: boolean isCurrentUser: boolean } @@ -41,7 +40,6 @@ async function loadInitialHumans(): Promise { id: toHumanCardId(user.id), userId: user.id, name: user.displayName, - email: user.email, status: user.isOnline ? "Online" : "Offline", isCurrentUser: user.isCurrentUser, })) diff --git a/frontend/components/direct-messages-sidebar.tsx b/frontend/components/direct-messages-sidebar.tsx index 3cf1c9b2..d72812ca 100644 --- a/frontend/components/direct-messages-sidebar.tsx +++ b/frontend/components/direct-messages-sidebar.tsx @@ -57,11 +57,7 @@ export function DirectMessagesSidebar({ if (!search.trim()) return sorted const q = search.toLowerCase() - return sorted.filter( - (h) => - h.name.toLowerCase().includes(q) || - h.email.toLowerCase().includes(q) - ) + return sorted.filter((h) => h.name.toLowerCase().includes(q)) }, [humans, search]) return ( diff --git a/frontend/lib/humans.ts b/frontend/lib/humans.ts index 947c58e1..061bfe86 100644 --- a/frontend/lib/humans.ts +++ b/frontend/lib/humans.ts @@ -4,7 +4,6 @@ export interface HumanMember { id: string userId: number name: string - email: string status: HumanStatus isCurrentUser: boolean } @@ -36,7 +35,6 @@ export function getCachedHumans(): HumanMember[] { typeof item.id === "string" && typeof item.userId === "number" && typeof item.name === "string" && - typeof item.email === "string" && (item.status === "Online" || item.status === "Offline") && typeof item.isCurrentUser === "boolean" ) diff --git a/frontend/proxy.ts b/frontend/middleware.ts similarity index 90% rename from frontend/proxy.ts rename to frontend/middleware.ts index 80405055..bc032a40 100644 --- a/frontend/proxy.ts +++ b/frontend/middleware.ts @@ -3,7 +3,7 @@ import { ACCESS_TOKEN_COOKIE_NAME } from "@/lib/server/auth" const PUBLIC_ROUTES = new Set(["/login", "/signup"]) -export function proxy(req: NextRequest) { +export function middleware(req: NextRequest) { const { pathname } = req.nextUrl if ( @@ -36,7 +36,7 @@ export function proxy(req: NextRequest) { if (!hasToken) { const loginUrl = new URL("/login", req.url) - loginUrl.searchParams.set("next", pathname) + loginUrl.searchParams.set("next", pathname + req.nextUrl.search) return NextResponse.redirect(loginUrl) } From 39f237c645e54dbceb03f7d464cc2281ed5b01c8 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sat, 21 Feb 2026 17:54:08 -0600 Subject: [PATCH 06/37] fix: resolve remaining minor PR review findings --- backend/.env.example | 18 ++++++------ backend/src/Api/Endpoints/AuthEndpoints.cs | 4 +-- backend/src/Api/appsettings.json | 4 +-- .../app/api/auth/register/verify/route.ts | 25 ++++++++++++++-- frontend/app/api/channels/create/route.ts | 9 +++++- frontend/app/api/providers/route.ts | 11 ++++++- frontend/app/api/users/route.ts | 4 +++ frontend/app/signup/page.tsx | 17 +++++++++-- frontend/components/home-page-client.tsx | 29 ++++++++++++------- 9 files changed, 89 insertions(+), 32 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index fc492500..3aea7280 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,14 +1,14 @@ -AZURE_OPENAI_API_KEY= AZURE_OPENAI_API_ENDPOINT= -JWT_SIGNING_KEY=ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars! -JWT_ISSUER=AzureOpsCrew -JWT_AUDIENCE=AzureOpsCrewFrontend +AZURE_OPENAI_API_KEY= +BREVO_API_BASE_URL=https://api.brevo.com +BREVO_API_KEY= +BREVO_SENDER_EMAIL=azureopscrew@aoc-app.com +BREVO_SENDER_NAME="Azure Ops Crew" EMAIL_VERIFICATION_CODE_LENGTH=6 EMAIL_VERIFICATION_CODE_TTL_MINUTES=10 -EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=30 EMAIL_VERIFICATION_MAX_ATTEMPTS=5 -BREVO_API_KEY= -BREVO_API_BASE_URL=https://api.brevo.com -BREVO_SENDER_EMAIL=azureopscrew@aoc-app.com -BREVO_SENDER_NAME=Azure Ops Crew +EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=30 +JWT_AUDIENCE=AzureOpsCrewFrontend +JWT_ISSUER=AzureOpsCrew +JWT_SIGNING_KEY=ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars! SEEDING_ENABLED=false diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs index d11cb2ff..77e72284 100644 --- a/backend/src/Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -93,7 +93,7 @@ await registrationEmailSender.SendRegistrationCodeAsync( expiresAtUtc, cancellationToken); } - catch (InvalidOperationException) + catch (Exception) when (cancellationToken.IsCancellationRequested is false) { return Results.Json( new { error = "Unable to send verification email. Please try again." }, @@ -170,7 +170,7 @@ await registrationEmailSender.SendRegistrationCodeAsync( expiresAtUtc, cancellationToken); } - catch (InvalidOperationException) + catch (Exception) when (cancellationToken.IsCancellationRequested is false) { return Results.Json( new { error = "Unable to send verification email. Please try again." }, diff --git a/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index 201e4fc8..0867449a 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -41,7 +41,5 @@ "ApiEndpoint": "", "Key": "" } - }, - "EnableSeeding": "false", - "SeedingProviderKey": "YOUR_API_KEY" + } } diff --git a/frontend/app/api/auth/register/verify/route.ts b/frontend/app/api/auth/register/verify/route.ts index ac38c6b8..689993d9 100644 --- a/frontend/app/api/auth/register/verify/route.ts +++ b/frontend/app/api/auth/register/verify/route.ts @@ -13,6 +13,25 @@ interface BackendAuthResponse { } } +function isValidBackendAuthResponse(value: unknown): value is BackendAuthResponse { + if (!value || typeof value !== "object") return false + + const data = value as Record + if (typeof data.accessToken !== "string" || data.accessToken.length === 0) return false + if (typeof data.expiresAtUtc !== "string" || Number.isNaN(Date.parse(data.expiresAtUtc))) return false + + const user = data.user + if (!user || typeof user !== "object") return false + + const typedUser = user as Record + return ( + typeof typedUser.id === "number" && + typeof typedUser.email === "string" && + typedUser.email.length > 0 && + typeof typedUser.displayName === "string" + ) +} + function extractErrorMessage(data: any, fallback: string) { if (typeof data?.error === "string") return data.error if (typeof data?.Error === "string") return data.Error @@ -44,11 +63,11 @@ export async function POST(req: NextRequest) { ) } - const authData = data as BackendAuthResponse - if (!authData.accessToken) { - return NextResponse.json({ error: "Missing access token" }, { status: 502 }) + if (!isValidBackendAuthResponse(data)) { + return NextResponse.json({ error: "Invalid auth response from backend" }, { status: 502 }) } + const authData = data const nextResponse = NextResponse.json({ expiresAtUtc: authData.expiresAtUtc, user: authData.user, diff --git a/frontend/app/api/channels/create/route.ts b/frontend/app/api/channels/create/route.ts index e881a34a..0c3c9bef 100644 --- a/frontend/app/api/channels/create/route.ts +++ b/frontend/app/api/channels/create/route.ts @@ -77,8 +77,15 @@ export async function POST(req: NextRequest) { } } + if (!channelId) { + return NextResponse.json( + { error: "Channel created but ID not returned by backend" }, + { status: 502 } + ) + } + const fallbackChannel: Channel = { - id: channelId || crypto.randomUUID(), + id: channelId, name: name.trim(), agentIds: agentIds || [], dateCreated: new Date().toISOString(), diff --git a/frontend/app/api/providers/route.ts b/frontend/app/api/providers/route.ts index 7c7b0af1..b6fab04e 100644 --- a/frontend/app/api/providers/route.ts +++ b/frontend/app/api/providers/route.ts @@ -32,6 +32,15 @@ function mapProviderType(type: number | string): string { return type } +function safeParseSelectedModels(value: string): string[] { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [] + } catch { + return [] + } +} + export async function GET(req: NextRequest) { try { if (!getAccessToken(req)) { @@ -65,7 +74,7 @@ export async function GET(req: NextRequest) { hasApiKey: p.hasApiKey ?? false, baseUrl: p.apiEndpoint ?? "", defaultModel: p.defaultModel ?? "", - selectedModels: p.selectedModels ? (JSON.parse(p.selectedModels) as string[]) : [], + selectedModels: p.selectedModels ? safeParseSelectedModels(p.selectedModels) : [], timeout: 30, rateLimit: 60, availableModels: [] as string[], diff --git a/frontend/app/api/users/route.ts b/frontend/app/api/users/route.ts index c08c48d3..a85863c3 100644 --- a/frontend/app/api/users/route.ts +++ b/frontend/app/api/users/route.ts @@ -32,6 +32,10 @@ export async function GET(req: NextRequest) { ) } + if (!Array.isArray(data)) { + return NextResponse.json({ error: "Invalid users response format" }, { status: 502 }) + } + const users = (data as BackendUserPresence[]).map( (user): HumanMember => ({ id: toHumanCardId(user.id), diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index ed8488ad..6b54873f 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -35,6 +35,7 @@ export default function SignupPage() { const [isSubmitting, setIsSubmitting] = useState(false) const [isResending, setIsResending] = useState(false) + const [now, setNow] = useState(() => Date.now()) useEffect(() => { if (resendCooldownSeconds <= 0) return @@ -48,15 +49,27 @@ export default function SignupPage() { } }, [resendCooldownSeconds]) + useEffect(() => { + if (!expiresAtUtc) return + + const timer = window.setInterval(() => { + setNow(Date.now()) + }, 1000) + + return () => { + window.clearInterval(timer) + } + }, [expiresAtUtc]) + const expiresInLabel = useMemo(() => { if (!expiresAtUtc) return null - const diffMs = new Date(expiresAtUtc).getTime() - Date.now() + const diffMs = new Date(expiresAtUtc).getTime() - now if (diffMs <= 0) return "expired" const diffMinutes = Math.ceil(diffMs / 60000) return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"}` - }, [expiresAtUtc, resendCooldownSeconds]) + }, [expiresAtUtc, now]) async function handleRequestCode(event: FormEvent) { event.preventDefault() diff --git a/frontend/components/home-page-client.tsx b/frontend/components/home-page-client.tsx index 36c244d6..1dc5300c 100644 --- a/frontend/components/home-page-client.tsx +++ b/frontend/components/home-page-client.tsx @@ -50,11 +50,19 @@ export default function HomePageClient({ initialHumans }: HomePageClientProps) { let isCancelled = false async function ensureAuthenticated() { - const response = await fetch("/api/auth/me") - if (!response.ok && !isCancelled) { - clearCachedHumans() - await fetch("/api/auth/logout", { method: "POST" }) - window.location.href = "/login" + try { + const response = await fetch("/api/auth/me") + if (!response.ok && !isCancelled) { + clearCachedHumans() + await fetch("/api/auth/logout", { method: "POST" }) + window.location.href = "/login" + } + } catch { + if (!isCancelled) { + clearCachedHumans() + await fetch("/api/auth/logout", { method: "POST" }).catch(() => {}) + window.location.href = "/login" + } } } @@ -184,18 +192,17 @@ export default function HomePageClient({ initialHumans }: HomePageClientProps) { } setChannels((prev) => { const next = prev.filter((c) => c.id !== channelId) + setActiveChannelId((current) => { + if (current !== channelId) return current + return next[0]?.id ?? "" + }) return next }) - setActiveChannelId((current) => { - if (current !== channelId) return current - const remaining = channels.filter((c) => c.id !== channelId) - return remaining[0]?.id ?? "" - }) } catch (error) { console.error("Failed to delete channel:", error) throw error } - }, [channels]) + }, []) const handleAddAgent = useCallback(async (agent: Agent) => { // Reload agents from backend after creation to ensure consistency From 285c2e4b3bc05414bf87f9b81a9763f0547ab263 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sat, 21 Feb 2026 18:40:38 -0600 Subject: [PATCH 07/37] fix: harden secret config defaults and signup challenge validation --- .../Extensions/ServiceCollectionExtensions.cs | 5 ++- backend/src/Api/appsettings.json | 4 +-- frontend/app/signup/page.tsx | 35 ++++++++++++++++--- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs index 413be042..4dc68a3b 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -126,9 +126,8 @@ public static void AddJwtAuthentication(this IServiceCollection services, IConfi if (settings.AccessTokenMinutes <= 0) throw new InvalidOperationException("Jwt__AccessTokenMinutes must be greater than zero."); - if (!environment.IsDevelopment() && - settings.SigningKey.Contains("ChangeThisDevelopmentOnly", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("A production JWT signing key must be configured."); + if (settings.SigningKey.Contains("ChangeThisDevelopmentOnly", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("A real JWT signing key must be configured."); var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey)); diff --git a/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index 0867449a..399512ce 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -20,7 +20,7 @@ "Jwt": { "Issuer": "AzureOpsCrew", "Audience": "AzureOpsCrewFrontend", - "SigningKey": "ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars!", + "SigningKey": "", "AccessTokenMinutes": 480 }, "EmailVerification": { @@ -31,7 +31,7 @@ }, "Brevo": { "ApiBaseUrl": "https://api.brevo.com", - "ApiKey": "CHANGEME", + "ApiKey": "", "SenderEmail": "azureopscrew@aoc-app.com", "SenderName": "Azure Ops Crew" }, diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 6b54873f..a3525467 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -12,6 +12,23 @@ interface RegisterChallengeResponse { resendAvailableInSeconds: number } +function parseRegisterChallengeResponse(data: unknown): RegisterChallengeResponse | null { + if (!data || typeof data !== "object") return null + + const challenge = data as Record + if (typeof challenge.message !== "string" || challenge.message.trim().length === 0) return null + if (typeof challenge.expiresAtUtc !== "string" || Number.isNaN(Date.parse(challenge.expiresAtUtc))) return null + + const resendSeconds = Number(challenge.resendAvailableInSeconds) + if (!Number.isFinite(resendSeconds)) return null + + return { + message: challenge.message, + expiresAtUtc: challenge.expiresAtUtc, + resendAvailableInSeconds: Math.max(0, resendSeconds), + } +} + function formatCountdown(totalSeconds: number) { const minutes = Math.floor(totalSeconds / 60) const seconds = totalSeconds % 60 @@ -98,10 +115,15 @@ export default function SignupPage() { return } - const challenge = data as RegisterChallengeResponse + const challenge = parseRegisterChallengeResponse(data) + if (!challenge) { + setError("Invalid response from server") + return + } + setChallengeMessage(challenge.message) setExpiresAtUtc(challenge.expiresAtUtc) - setResendCooldownSeconds(Math.max(0, challenge.resendAvailableInSeconds ?? 0)) + setResendCooldownSeconds(challenge.resendAvailableInSeconds) setVerificationCode("") setStep("verify") } catch { @@ -164,10 +186,15 @@ export default function SignupPage() { return } - const challenge = data as RegisterChallengeResponse + const challenge = parseRegisterChallengeResponse(data) + if (!challenge) { + setError("Invalid response from server") + return + } + setChallengeMessage(challenge.message) setExpiresAtUtc(challenge.expiresAtUtc) - setResendCooldownSeconds(Math.max(0, challenge.resendAvailableInSeconds ?? 0)) + setResendCooldownSeconds(challenge.resendAvailableInSeconds) } catch { setError("Unable to resend verification code. Please try again.") } finally { From e324a13555ee0d8d0cfb22f15451ae04551d889a Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 03:33:58 -0600 Subject: [PATCH 08/37] Integrate Keycloak OIDC auth flow for test1 deploy --- .github/workflows/deploy.yml | 13 +- backend/.env.example | 4 + backend/docker-compose.yml | 4 + .../src/Api/Auth/KeycloakIdTokenValidator.cs | 81 ++++++++ backend/src/Api/Endpoints/AuthEndpoints.cs | 187 ++++++++++++++++++ .../Dtos/Auth/KeycloakExchangeRequestDto.cs | 9 + .../Extensions/ServiceCollectionExtensions.cs | 24 +++ backend/src/Api/Program.cs | 1 + .../src/Api/Settings/KeycloakOidcSettings.cs | 9 + backend/src/Api/appsettings.json | 6 + .../src/Domain/Users/UserExternalIdentity.cs | 35 ++++ .../Infrastructure.Db/AzureOpsCrewContext.cs | 3 + ...ExternalIdentityEntityTypeConfiguration.cs | 41 ++++ .../M009_AddUserExternalIdentityTable.cs | 41 ++++ frontend/.env.example | 9 + .../app/api/auth/keycloak/callback/route.ts | 138 +++++++++++++ frontend/app/api/auth/keycloak/start/route.ts | 46 +++++ frontend/app/login/page.tsx | 24 ++- frontend/app/signup/page.tsx | 24 +++ frontend/lib/server/keycloak.ts | 81 ++++++++ 20 files changed, 776 insertions(+), 4 deletions(-) create mode 100644 backend/src/Api/Auth/KeycloakIdTokenValidator.cs create mode 100644 backend/src/Api/Endpoints/Dtos/Auth/KeycloakExchangeRequestDto.cs create mode 100644 backend/src/Api/Settings/KeycloakOidcSettings.cs create mode 100644 backend/src/Domain/Users/UserExternalIdentity.cs create mode 100644 backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs create mode 100644 backend/src/Infrastructure.Db/Migrations/M009_AddUserExternalIdentityTable.cs create mode 100644 frontend/app/api/auth/keycloak/callback/route.ts create mode 100644 frontend/app/api/auth/keycloak/start/route.ts create mode 100644 frontend/lib/server/keycloak.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ea93c01e..e624ecad 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,6 +19,8 @@ on: env: ACR_NAME: azopscrewacr2dovm8 ACR_SERVER: azopscrewacr2dovm8.azurecr.io + KEYCLOAK_AUTHORITY: https://aus.aoc-app.com/realms/azureopscrew + KEYCLOAK_CLIENT_ID: azureopscrew-frontend jobs: build: @@ -119,7 +121,11 @@ jobs: Brevo__ApiBaseUrl=https://api.brevo.com \ Brevo__ApiKey=${{ secrets.BREVO_API_KEY }} \ Brevo__SenderEmail=${{ secrets.BREVO_SENDER_EMAIL }} \ - Brevo__SenderName="${{ secrets.BREVO_SENDER_NAME }}" + Brevo__SenderName="${{ secrets.BREVO_SENDER_NAME }}" \ + KeycloakOidc__Enabled=true \ + KeycloakOidc__Authority=${{ env.KEYCLOAK_AUTHORITY }} \ + KeycloakOidc__ClientId=${{ env.KEYCLOAK_CLIENT_ID }} \ + KeycloakOidc__RequireVerifiedEmail=true echo "✅ Backend deployed" - name: Deploy Frontend @@ -128,7 +134,10 @@ jobs: az containerapp update \ --name ca-azopscrew-frontend-${{ needs.build.outputs.env_name }} \ --resource-group ${{ needs.build.outputs.resource_group }} \ - --image ${{ env.ACR_SERVER }}/frontend:${{ needs.build.outputs.image_tag }} + --image ${{ env.ACR_SERVER }}/frontend:${{ needs.build.outputs.image_tag }} \ + --set-env-vars \ + KEYCLOAK_AUTHORITY=${{ env.KEYCLOAK_AUTHORITY }} \ + KEYCLOAK_CLIENT_ID=${{ env.KEYCLOAK_CLIENT_ID }} echo "✅ Frontend deployed" - name: Deploy Summary diff --git a/backend/.env.example b/backend/.env.example index 3aea7280..5fae3ece 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,10 @@ EMAIL_VERIFICATION_CODE_LENGTH=6 EMAIL_VERIFICATION_CODE_TTL_MINUTES=10 EMAIL_VERIFICATION_MAX_ATTEMPTS=5 EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=30 +KEYCLOAK_OIDC_ENABLED=false +KEYCLOAK_OIDC_AUTHORITY= +KEYCLOAK_OIDC_CLIENT_ID= +KEYCLOAK_OIDC_REQUIRE_VERIFIED_EMAIL=true JWT_AUDIENCE=AzureOpsCrewFrontend JWT_ISSUER=AzureOpsCrew JWT_SIGNING_KEY=ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars! diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 35e18488..b3aa0ada 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -22,6 +22,10 @@ services: - Brevo__ApiKey=${BREVO_API_KEY:-} - Brevo__SenderEmail=${BREVO_SENDER_EMAIL:-azureopscrew@aoc-app.com} - Brevo__SenderName=${BREVO_SENDER_NAME:-Azure Ops Crew} + - KeycloakOidc__Enabled=${KEYCLOAK_OIDC_ENABLED:-false} + - KeycloakOidc__Authority=${KEYCLOAK_OIDC_AUTHORITY:-} + - KeycloakOidc__ClientId=${KEYCLOAK_OIDC_CLIENT_ID:-} + - KeycloakOidc__RequireVerifiedEmail=${KEYCLOAK_OIDC_REQUIRE_VERIFIED_EMAIL:-true} - Seeding__IsEnabled=${SEEDING_ENABLED:-false} - Seeding__AzureFoundrySeed__ApiEndpoint=${AZURE_OPENAI_API_ENDPOINT:-} - Seeding__AzureFoundrySeed__Key=${AZURE_OPENAI_API_KEY:-} diff --git a/backend/src/Api/Auth/KeycloakIdTokenValidator.cs b/backend/src/Api/Auth/KeycloakIdTokenValidator.cs new file mode 100644 index 00000000..cb3973bb --- /dev/null +++ b/backend/src/Api/Auth/KeycloakIdTokenValidator.cs @@ -0,0 +1,81 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using AzureOpsCrew.Api.Settings; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace AzureOpsCrew.Api.Auth; + +public sealed class KeycloakIdTokenValidator +{ + private readonly KeycloakOidcSettings _settings; + private readonly ConfigurationManager? _configurationManager; + private readonly JwtSecurityTokenHandler _tokenHandler = new(); + + public KeycloakIdTokenValidator(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _settings = options.Value; + if (!_settings.Enabled) + return; + + if (string.IsNullOrWhiteSpace(_settings.Authority)) + throw new InvalidOperationException("KeycloakOidc__Authority is required when KeycloakOidc__Enabled=true."); + + if (string.IsNullOrWhiteSpace(_settings.ClientId)) + throw new InvalidOperationException("KeycloakOidc__ClientId is required when KeycloakOidc__Enabled=true."); + + var authority = _settings.Authority.TrimEnd('/'); + var metadataAddress = $"{authority}/.well-known/openid-configuration"; + var retriever = new HttpDocumentRetriever { RequireHttps = true }; + + _configurationManager = new ConfigurationManager( + metadataAddress, + new OpenIdConnectConfigurationRetriever(), + retriever); + } + + public bool IsEnabled => _settings.Enabled; + + public async Task ValidateIdTokenAsync(string idToken, CancellationToken cancellationToken) + { + if (!_settings.Enabled || _configurationManager is null) + throw new InvalidOperationException("Keycloak OIDC validation is not enabled."); + + if (string.IsNullOrWhiteSpace(idToken)) + throw new SecurityTokenException("ID token is required."); + + var configuration = await _configurationManager.GetConfigurationAsync(cancellationToken); + return Validate(idToken, configuration); + } + + private ClaimsPrincipal Validate(string idToken, OpenIdConnectConfiguration configuration) + { + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = _settings.Authority.TrimEnd('/'), + ValidateAudience = true, + ValidAudience = _settings.ClientId, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = configuration.SigningKeys, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + RequireSignedTokens = true, + NameClaimType = "name" + }; + + try + { + return _tokenHandler.ValidateToken(idToken, validationParameters, out _); + } + catch (SecurityTokenSignatureKeyNotFoundException) + { + _configurationManager!.RequestRefresh(); + throw; + } + } +} diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs index 77e72284..15ccaadb 100644 --- a/backend/src/Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using System.Security.Cryptography; +using System.IdentityModel.Tokens.Jwt; namespace AzureOpsCrew.Api.Endpoints; @@ -328,6 +330,145 @@ await registrationEmailSender.SendRegistrationCodeAsync( .Produces(StatusCodes.Status401Unauthorized) .AllowAnonymous(); + group.MapPost("/keycloak/exchange", async ( + KeycloakExchangeRequestDto body, + AzureOpsCrewContext context, + KeycloakIdTokenValidator keycloakIdTokenValidator, + IOptions keycloakOidcOptions, + IPasswordHasher passwordHasher, + JwtTokenService jwtTokenService, + CancellationToken cancellationToken) => + { + if (!keycloakIdTokenValidator.IsEnabled) + { + return Results.Json( + new { error = "Keycloak sign-in is not enabled." }, + statusCode: StatusCodes.Status501NotImplemented); + } + + System.Security.Claims.ClaimsPrincipal principal; + try + { + principal = await keycloakIdTokenValidator.ValidateIdTokenAsync(body.IdToken.Trim(), cancellationToken); + } + catch (SecurityTokenException) + { + return Results.Unauthorized(); + } + + var providerSubject = GetFirstClaimValue( + principal, + JwtRegisteredClaimNames.Sub, + System.Security.Claims.ClaimTypes.NameIdentifier); + + if (string.IsNullOrWhiteSpace(providerSubject)) + { + return Results.BadRequest(new { error = "Missing subject claim in identity token." }); + } + + var email = GetFirstClaimValue( + principal, + JwtRegisteredClaimNames.Email, + System.Security.Claims.ClaimTypes.Email)?.Trim(); + + if (string.IsNullOrWhiteSpace(email)) + { + return Results.BadRequest(new { error = "Missing email claim in identity token." }); + } + + var emailVerified = false; + if (keycloakOidcOptions.Value.RequireVerifiedEmail && !TryGetBooleanClaim(principal, "email_verified", out emailVerified)) + { + return Results.BadRequest(new { error = "Missing email verification claim in identity token." }); + } + + if (keycloakOidcOptions.Value.RequireVerifiedEmail && emailVerified is false) + { + return Results.Json( + new { error = "Email address is not verified." }, + statusCode: StatusCodes.Status403Forbidden); + } + + const string provider = "keycloak"; + var normalizedEmail = NormalizeEmail(email); + var displayName = ResolveDisplayName(principal, email); + + var linkedIdentity = await context.UserExternalIdentities + .SingleOrDefaultAsync( + x => x.Provider == provider && x.ProviderSubject == providerSubject, + cancellationToken); + + User? user = null; + if (linkedIdentity is not null) + { + user = await context.Users + .SingleOrDefaultAsync(u => u.Id == linkedIdentity.UserId, cancellationToken); + + if (user is null) + { + context.UserExternalIdentities.Remove(linkedIdentity); + await context.SaveChangesAsync(cancellationToken); + linkedIdentity = null; + } + } + + if (user is null) + { + user = await context.Users + .SingleOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); + + if (user is null) + { + user = new User( + email: email, + normalizedEmail: normalizedEmail, + passwordHash: string.Empty, + displayName: displayName); + + var externalOnlyPasswordHash = passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + user.UpdatePasswordHash(externalOnlyPasswordHash); + + context.Users.Add(user); + await context.SaveChangesAsync(cancellationToken); + } + } + + if (linkedIdentity is null) + { + linkedIdentity = new UserExternalIdentity(user.Id, provider, providerSubject, email); + context.UserExternalIdentities.Add(linkedIdentity); + } + else + { + linkedIdentity.UpdateEmail(email); + } + + if (!string.IsNullOrWhiteSpace(displayName) && !string.Equals(user.DisplayName, displayName, StringComparison.Ordinal)) + { + user.UpdateDisplayName(displayName); + } + + if (!user.IsActive) + { + return Results.Json( + new { error = "User is deactivated." }, + statusCode: StatusCodes.Status403Forbidden); + } + + user.MarkLogin(); + await context.SaveChangesAsync(cancellationToken); + + var token = jwtTokenService.CreateToken(user); + return Results.Ok(ToAuthResponse(user, token)); + }) + .AddEndpointFilter>() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status501NotImplemented) + .AllowAnonymous(); + group.MapGet("/me", async ( HttpContext httpContext, AzureOpsCrewContext context, @@ -380,4 +521,50 @@ private static AuthResponseDto ToAuthResponse(User user, AuthTokenResult token) token.ExpiresAtUtc, new AuthUserDto(user.Id, user.Email, user.DisplayName)); } + + private static string ResolveDisplayName(System.Security.Claims.ClaimsPrincipal principal, string email) + { + var fromClaims = GetFirstClaimValue( + principal, + "name", + System.Security.Claims.ClaimTypes.Name, + "preferred_username"); + + if (!string.IsNullOrWhiteSpace(fromClaims)) + return fromClaims.Trim(); + + var atIndex = email.IndexOf('@'); + return atIndex > 0 ? email[..atIndex] : email; + } + + private static string? GetFirstClaimValue(System.Security.Claims.ClaimsPrincipal principal, params string[] claimTypes) + { + foreach (var claimType in claimTypes) + { + var value = principal.FindFirst(claimType)?.Value; + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + return null; + } + + private static bool TryGetBooleanClaim(System.Security.Claims.ClaimsPrincipal principal, string claimType, out bool value) + { + var raw = principal.FindFirst(claimType)?.Value; + if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1") + { + value = true; + return true; + } + + if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase) || raw == "0") + { + value = false; + return true; + } + + value = false; + return false; + } } diff --git a/backend/src/Api/Endpoints/Dtos/Auth/KeycloakExchangeRequestDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/KeycloakExchangeRequestDto.cs new file mode 100644 index 00000000..89af14ab --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Auth/KeycloakExchangeRequestDto.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; + +public sealed class KeycloakExchangeRequestDto +{ + [Required] + public string IdToken { get; set; } = string.Empty; +} diff --git a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs index 4dc68a3b..f4e8d24c 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -208,4 +208,28 @@ public static void AddEmailVerification(this IServiceCollection services, IConfi services.AddScoped, PasswordHasher>(); } + + public static void AddKeycloakOidcSupport(this IServiceCollection services, IConfiguration configuration) + { + var settings = configuration.GetSection("KeycloakOidc").Get() ?? new KeycloakOidcSettings(); + + if (settings.Enabled) + { + if (string.IsNullOrWhiteSpace(settings.Authority)) + throw new InvalidOperationException("KeycloakOidc__Authority is required when KeycloakOidc__Enabled=true."); + + if (!Uri.TryCreate(settings.Authority, UriKind.Absolute, out var authorityUri)) + throw new InvalidOperationException("KeycloakOidc__Authority must be an absolute URL."); + + if (!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("KeycloakOidc__Authority must use HTTPS."); + + if (string.IsNullOrWhiteSpace(settings.ClientId)) + throw new InvalidOperationException("KeycloakOidc__ClientId is required when KeycloakOidc__Enabled=true."); + } + + services.Configure(configuration.GetSection("KeycloakOidc")); + services.AddOptions(); + services.AddSingleton(); + } } diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index 428812e6..5aa062f4 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddProviderFacades(); builder.Services.AddJwtAuthentication(builder.Configuration, builder.Environment); builder.Services.AddEmailVerification(builder.Configuration); + builder.Services.AddKeycloakOidcSupport(builder.Configuration); // Configure AG-UI builder.Services.AddHttpClient(); diff --git a/backend/src/Api/Settings/KeycloakOidcSettings.cs b/backend/src/Api/Settings/KeycloakOidcSettings.cs new file mode 100644 index 00000000..72e0d287 --- /dev/null +++ b/backend/src/Api/Settings/KeycloakOidcSettings.cs @@ -0,0 +1,9 @@ +namespace AzureOpsCrew.Api.Settings; + +public sealed class KeycloakOidcSettings +{ + public bool Enabled { get; set; } + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public bool RequireVerifiedEmail { get; set; } = true; +} diff --git a/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index 399512ce..24ab88b8 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -35,6 +35,12 @@ "SenderEmail": "azureopscrew@aoc-app.com", "SenderName": "Azure Ops Crew" }, + "KeycloakOidc": { + "Enabled": false, + "Authority": "", + "ClientId": "", + "RequireVerifiedEmail": true + }, "Seeding": { "IsEnabled": false, "AzureFoundrySeed": { diff --git a/backend/src/Domain/Users/UserExternalIdentity.cs b/backend/src/Domain/Users/UserExternalIdentity.cs new file mode 100644 index 00000000..2348aa4b --- /dev/null +++ b/backend/src/Domain/Users/UserExternalIdentity.cs @@ -0,0 +1,35 @@ +#pragma warning disable CS8618 + +namespace AzureOpsCrew.Domain.Users; + +public sealed class UserExternalIdentity +{ + private UserExternalIdentity() + { + } + + public UserExternalIdentity(int userId, string provider, string providerSubject, string? email) + { + UserId = userId; + Provider = provider; + ProviderSubject = providerSubject; + Email = email; + } + + public int Id { get; private set; } + public int UserId { get; private set; } + public string Provider { get; private set; } + public string ProviderSubject { get; private set; } + public string? Email { get; private set; } + public DateTime DateCreated { get; private set; } = DateTime.UtcNow; + public DateTime? DateModified { get; private set; } + + public void UpdateEmail(string? email) + { + if (string.Equals(Email, email, StringComparison.Ordinal)) + return; + + Email = email; + DateModified = DateTime.UtcNow; + } +} diff --git a/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs b/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs index b0396d30..f9ba9414 100644 --- a/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs +++ b/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs @@ -7,6 +7,7 @@ using AiProviderConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.ProviderEntityTypeConfiguration; using PendingRegistrationConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.PendingRegistrationEntityTypeConfiguration; using UserConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.UserEntityTypeConfiguration; +using UserExternalIdentityConfig = AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite.UserExternalIdentityEntityTypeConfiguration; using AiProvider = AzureOpsCrew.Domain.Providers.Provider; namespace AzureOpsCrew.Infrastructure.Db; @@ -23,6 +24,7 @@ public AzureOpsCrewContext(DbContextOptions options) public DbSet Providers => Set(); public DbSet Users => Set(); public DbSet PendingRegistrations => Set(); + public DbSet UserExternalIdentities => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -30,6 +32,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new ChannelConfig()); modelBuilder.ApplyConfiguration(new AiProviderConfig()); modelBuilder.ApplyConfiguration(new UserConfig()); + modelBuilder.ApplyConfiguration(new UserExternalIdentityConfig()); modelBuilder.ApplyConfiguration(new PendingRegistrationConfig()); } } diff --git a/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs new file mode 100644 index 00000000..6ce149ea --- /dev/null +++ b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs @@ -0,0 +1,41 @@ +using AzureOpsCrew.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AzureOpsCrew.Infrastructure.Db.EntityTypes.Sqlite; + +public sealed class UserExternalIdentityEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AppUserExternalIdentity"); + + builder.HasKey(x => x.Id); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + + builder.Property(x => x.UserId) + .IsRequired(); + + builder.Property(x => x.Provider) + .IsRequired() + .HasMaxLength(50); + + builder.Property(x => x.ProviderSubject) + .IsRequired() + .HasMaxLength(256); + + builder.Property(x => x.Email) + .HasMaxLength(320); + + builder.Property(x => x.DateCreated) + .IsRequired(); + + builder.Property(x => x.DateModified); + + builder.HasIndex(x => new { x.Provider, x.ProviderSubject }) + .IsUnique(); + + builder.HasIndex(x => x.UserId); + } +} diff --git a/backend/src/Infrastructure.Db/Migrations/M009_AddUserExternalIdentityTable.cs b/backend/src/Infrastructure.Db/Migrations/M009_AddUserExternalIdentityTable.cs new file mode 100644 index 00000000..8ee96579 --- /dev/null +++ b/backend/src/Infrastructure.Db/Migrations/M009_AddUserExternalIdentityTable.cs @@ -0,0 +1,41 @@ +using FluentMigrator; + +namespace AzureOpsCrew.Infrastructure.Db.Migrations; + +[Migration(2026_02_22_18_00_00, "Add AppUserExternalIdentity table for external IdP mappings")] +public class M009_AddUserExternalIdentityTable : Migration +{ + public override void Up() + { + Create.Table("AppUserExternalIdentity") + .WithColumn("Id").AsInt32().PrimaryKey().Identity() + .WithColumn("UserId").AsInt32().NotNullable() + .WithColumn("Provider").AsString(50).NotNullable() + .WithColumn("ProviderSubject").AsString(256).NotNullable() + .WithColumn("Email").AsString(320).Nullable() + .WithColumn("DateCreated").AsDateTime().NotNullable() + .WithColumn("DateModified").AsDateTime().Nullable(); + + Create.ForeignKey("FK_AppUserExternalIdentity_AppUser_UserId") + .FromTable("AppUserExternalIdentity").ForeignColumn("UserId") + .ToTable("AppUser").PrimaryColumn("Id"); + + Create.Index("IX_AppUserExternalIdentity_Provider_Subject") + .OnTable("AppUserExternalIdentity") + .OnColumn("Provider").Ascending() + .OnColumn("ProviderSubject").Ascending() + .WithOptions().Unique(); + + Create.Index("IX_AppUserExternalIdentity_UserId") + .OnTable("AppUserExternalIdentity") + .OnColumn("UserId"); + } + + public override void Down() + { + Delete.Index("IX_AppUserExternalIdentity_UserId").OnTable("AppUserExternalIdentity"); + Delete.Index("IX_AppUserExternalIdentity_Provider_Subject").OnTable("AppUserExternalIdentity"); + Delete.ForeignKey("FK_AppUserExternalIdentity_AppUser_UserId").OnTable("AppUserExternalIdentity"); + Delete.Table("AppUserExternalIdentity"); + } +} diff --git a/frontend/.env.example b/frontend/.env.example index b7e9263d..3acaab9b 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,3 +2,12 @@ # Default when unset: http://localhost:5000 # If backend runs via docker-compose from /backend, use: http://localhost:42100 BACKEND_API_URL= + +# Keycloak OIDC configuration (used by Next.js auth callback routes) +# Example authority: https://aus.aoc-app.com/realms/master +KEYCLOAK_AUTHORITY= +KEYCLOAK_CLIENT_ID= +# Optional for confidential clients (leave empty for public PKCE client) +KEYCLOAK_CLIENT_SECRET= +# Optional override when running behind a reverse proxy / custom frontend domain +KEYCLOAK_CALLBACK_URL= diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts new file mode 100644 index 00000000..e8b2bdc0 --- /dev/null +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME, getAuthCookieOptions } from "@/lib/server/auth" +import { + buildKeycloakCallbackUrl, + clearTransientAuthCookieOptions, + getKeycloakWebConfig, + KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, + KEYCLOAK_NEXT_COOKIE_NAME, + KEYCLOAK_STATE_COOKIE_NAME, + toSafeNextPath, +} from "@/lib/server/keycloak" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +interface BackendAuthResponse { + accessToken: string + expiresAtUtc: string + user: { + id: number + email: string + displayName: string + } +} + +function buildLoginRedirect(req: NextRequest, message: string) { + const loginUrl = new URL("/login", req.url) + loginUrl.searchParams.set("error", message) + return loginUrl +} + +export async function GET(req: NextRequest) { + const clearCookieOptions = clearTransientAuthCookieOptions() + const config = getKeycloakWebConfig() + if (!config) { + return NextResponse.redirect(buildLoginRedirect(req, "Keycloak is not configured")) + } + + const error = req.nextUrl.searchParams.get("error") + if (error) { + const errorDescription = req.nextUrl.searchParams.get("error_description") + const response = NextResponse.redirect( + buildLoginRedirect(req, errorDescription ?? error) + ) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + return response + } + + const code = req.nextUrl.searchParams.get("code") + const state = req.nextUrl.searchParams.get("state") + const expectedState = req.cookies.get(KEYCLOAK_STATE_COOKIE_NAME)?.value + const codeVerifier = req.cookies.get(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME)?.value + const nextPath = toSafeNextPath(req.cookies.get(KEYCLOAK_NEXT_COOKIE_NAME)?.value ?? null) + + if (!code || !state || !expectedState || state !== expectedState || !codeVerifier) { + const response = NextResponse.redirect(buildLoginRedirect(req, "Invalid sign-in callback")) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + return response + } + + try { + const callbackUrl = buildKeycloakCallbackUrl(req) + const tokenUrl = `${config.authority}/protocol/openid-connect/token` + + const tokenRequestBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: config.clientId, + redirect_uri: callbackUrl, + code_verifier: codeVerifier, + }) + + if (config.clientSecret) { + tokenRequestBody.set("client_secret", config.clientSecret) + } + + const tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenRequestBody.toString(), + cache: "no-store", + }) + + const tokenData = await tokenResponse.json().catch(() => ({})) + if (!tokenResponse.ok || typeof tokenData?.id_token !== "string") { + const response = NextResponse.redirect(buildLoginRedirect(req, "Keycloak sign-in failed")) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + return response + } + + const backendResponse = await fetch(`${BACKEND_API_URL}/api/auth/keycloak/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ idToken: tokenData.id_token }), + cache: "no-store", + }) + + const backendData = await backendResponse.json().catch(() => ({})) + if (!backendResponse.ok) { + const errorMessage = + typeof backendData?.error === "string" ? backendData.error : "Unable to complete sign-in" + const response = NextResponse.redirect(buildLoginRedirect(req, errorMessage)) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + return response + } + + const authData = backendData as BackendAuthResponse + if (!authData.accessToken) { + const response = NextResponse.redirect(buildLoginRedirect(req, "Invalid auth response")) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + return response + } + + const redirectUrl = new URL(nextPath, req.url) + const response = NextResponse.redirect(redirectUrl) + response.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + return response + } catch (error) { + console.error("Keycloak callback error:", error) + const response = NextResponse.redirect(buildLoginRedirect(req, "Unable to complete sign-in")) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + return response + } +} diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts new file mode 100644 index 00000000..68e0ac80 --- /dev/null +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server" +import { + buildKeycloakCallbackUrl, + createPkcePair, + createRandomState, + getKeycloakWebConfig, + getTransientAuthCookieOptions, + KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, + KEYCLOAK_NEXT_COOKIE_NAME, + KEYCLOAK_STATE_COOKIE_NAME, + toSafeNextPath, +} from "@/lib/server/keycloak" + +export async function GET(req: NextRequest) { + const config = getKeycloakWebConfig() + if (!config) { + return NextResponse.redirect(new URL("/login?error=Keycloak%20is%20not%20configured", req.url)) + } + + const mode = req.nextUrl.searchParams.get("mode") + const nextPath = toSafeNextPath(req.nextUrl.searchParams.get("next")) + const callbackUrl = buildKeycloakCallbackUrl(req) + + const state = createRandomState() + const { verifier, challenge } = createPkcePair() + + const authUrl = new URL(`${config.authority}/protocol/openid-connect/auth`) + authUrl.searchParams.set("client_id", config.clientId) + authUrl.searchParams.set("response_type", "code") + authUrl.searchParams.set("scope", "openid profile email") + authUrl.searchParams.set("redirect_uri", callbackUrl) + authUrl.searchParams.set("state", state) + authUrl.searchParams.set("code_challenge", challenge) + authUrl.searchParams.set("code_challenge_method", "S256") + + if (mode === "signup") { + authUrl.searchParams.set("kc_action", "register") + } + + const response = NextResponse.redirect(authUrl) + const cookieOptions = getTransientAuthCookieOptions() + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, state, cookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, verifier, cookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, nextPath, cookieOptions) + return response +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index c4ddd9e1..1372b2da 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -23,6 +23,9 @@ function LoginPageContent() { const [password, setPassword] = useState("") const [error, setError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) + const nextPath = toSafeNextPath(searchParams.get("next")) + const keycloakError = searchParams.get("error") + const keycloakLoginHref = `/api/auth/keycloak/start?mode=login&next=${encodeURIComponent(nextPath)}` async function handleSubmit(event: FormEvent) { event.preventDefault() @@ -42,7 +45,6 @@ function LoginPageContent() { return } - const nextPath = toSafeNextPath(searchParams.get("next")) router.replace(nextPath) router.refresh() } catch { @@ -65,6 +67,24 @@ function LoginPageContent() { +
+ + Continue with Secure Sign In + +

+ Recommended for production. Uses Keycloak (OIDC + PKCE). +

+
+ +
+
+ Legacy email login +
+
+
- {error &&

{error}

} + {(error || keycloakError) &&

{error ?? keycloakError}

}
+ {step === "details" && ( + <> +
+ + Continue with Secure Sign Up + +

+ Recommended for production. Uses Keycloak (OIDC + PKCE). +

+
+
+
+ + Legacy email code sign up + +
+
+ + )} + {step === "details" ? (
-
-
- Legacy email login -
-
- - - - - - - {(error || keycloakError) &&

{error ?? keycloakError}

} - - - + {keycloakError &&

{keycloakError}

}
) diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 850c00b9..0d3c869a 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,216 +1,9 @@ "use client" -import { FormEvent, useEffect, useMemo, useState } from "react" import Link from "next/link" -import { useRouter } from "next/navigation" - -type SignupStep = "details" | "verify" - -interface RegisterChallengeResponse { - message: string - expiresAtUtc: string - resendAvailableInSeconds: number -} - -function parseRegisterChallengeResponse(data: unknown): RegisterChallengeResponse | null { - if (!data || typeof data !== "object") return null - - const challenge = data as Record - if (typeof challenge.message !== "string" || challenge.message.trim().length === 0) return null - if (typeof challenge.expiresAtUtc !== "string" || Number.isNaN(Date.parse(challenge.expiresAtUtc))) return null - - const resendSeconds = Number(challenge.resendAvailableInSeconds) - if (!Number.isFinite(resendSeconds)) return null - - return { - message: challenge.message, - expiresAtUtc: challenge.expiresAtUtc, - resendAvailableInSeconds: Math.max(0, resendSeconds), - } -} - -function formatCountdown(totalSeconds: number) { - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return `${minutes}:${seconds.toString().padStart(2, "0")}` -} export default function SignupPage() { - const router = useRouter() const keycloakSignupHref = "/api/auth/keycloak/start?mode=signup" - const [step, setStep] = useState("details") - - const [displayName, setDisplayName] = useState("") - const [email, setEmail] = useState("") - const [password, setPassword] = useState("") - const [confirmPassword, setConfirmPassword] = useState("") - - const [verificationCode, setVerificationCode] = useState("") - const [expiresAtUtc, setExpiresAtUtc] = useState(null) - const [resendCooldownSeconds, setResendCooldownSeconds] = useState(0) - const [challengeMessage, setChallengeMessage] = useState(null) - const [error, setError] = useState(null) - - const [isSubmitting, setIsSubmitting] = useState(false) - const [isResending, setIsResending] = useState(false) - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - if (resendCooldownSeconds <= 0) return - - const timer = window.setInterval(() => { - setResendCooldownSeconds((current) => Math.max(0, current - 1)) - }, 1000) - - return () => { - window.clearInterval(timer) - } - }, [resendCooldownSeconds]) - - useEffect(() => { - if (!expiresAtUtc) return - - const timer = window.setInterval(() => { - setNow(Date.now()) - }, 1000) - - return () => { - window.clearInterval(timer) - } - }, [expiresAtUtc]) - - const expiresInLabel = useMemo(() => { - if (!expiresAtUtc) return null - - const diffMs = new Date(expiresAtUtc).getTime() - now - if (diffMs <= 0) return "expired" - - const diffMinutes = Math.ceil(diffMs / 60000) - return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"}` - }, [expiresAtUtc, now]) - - async function handleRequestCode(event: FormEvent) { - event.preventDefault() - setError(null) - - if (password !== confirmPassword) { - setError("Passwords do not match") - return - } - - setIsSubmitting(true) - try { - const response = await fetch("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email, - password, - displayName: displayName.trim() || undefined, - }), - }) - - const data = await response.json().catch(() => ({})) - if (!response.ok) { - setError(data?.error ?? "Unable to start registration") - return - } - - const challenge = parseRegisterChallengeResponse(data) - if (!challenge) { - setError("Invalid response from server") - return - } - - setChallengeMessage(challenge.message) - setExpiresAtUtc(challenge.expiresAtUtc) - setResendCooldownSeconds(challenge.resendAvailableInSeconds) - setVerificationCode("") - setStep("verify") - } catch { - setError("Unable to start registration. Please try again.") - } finally { - setIsSubmitting(false) - } - } - - async function handleVerifyCode(event: FormEvent) { - event.preventDefault() - setError(null) - - const code = verificationCode.trim() - if (!/^\d{4,8}$/.test(code)) { - setError("Enter a valid numeric verification code") - return - } - - setIsSubmitting(true) - try { - const response = await fetch("/api/auth/register/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email, - code, - }), - }) - - const data = await response.json().catch(() => ({})) - if (!response.ok) { - setError(data?.error ?? "Verification failed") - return - } - - router.replace("/") - router.refresh() - } catch { - setError("Unable to verify code. Please try again.") - } finally { - setIsSubmitting(false) - } - } - - async function handleResendCode() { - setError(null) - setIsResending(true) - - try { - const response = await fetch("/api/auth/register/resend", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }) - - const data = await response.json().catch(() => ({})) - if (!response.ok) { - setError(data?.error ?? "Unable to resend verification code") - return - } - - const challenge = parseRegisterChallengeResponse(data) - if (!challenge) { - setError("Invalid response from server") - return - } - - setChallengeMessage(challenge.message) - setExpiresAtUtc(challenge.expiresAtUtc) - setResendCooldownSeconds(challenge.resendAvailableInSeconds) - } catch { - setError("Unable to resend verification code. Please try again.") - } finally { - setIsResending(false) - } - } - - function handleChangeEmail() { - setStep("details") - setVerificationCode("") - setChallengeMessage(null) - setExpiresAtUtc(null) - setResendCooldownSeconds(0) - setError(null) - } return (
@@ -225,152 +18,18 @@ export default function SignupPage() { - {step === "details" && ( - <> -
- - Continue with Secure Sign Up - -

- Recommended for production. Uses Keycloak (OIDC + PKCE). -

-
-
-
- - Legacy email code sign up - -
-
- - )} - - {step === "details" ? ( -
- - - - - - - - - {error &&

{error}

} - - -
- ) : ( -
-
- {challengeMessage ?? "We sent a verification code to your email."} -
- Email: {email} -
- {expiresInLabel && ( -
Code expires in {expiresInLabel}.
- )} -
- - - - {error &&

{error}

} - - - -
- +
+ + Continue with Sign Up + - -
- - )} +
+ Registration is handled only through Keycloak. +
+
) diff --git a/frontend/middleware.ts b/frontend/proxy.ts similarity index 96% rename from frontend/middleware.ts rename to frontend/proxy.ts index bc032a40..1a8a7082 100644 --- a/frontend/middleware.ts +++ b/frontend/proxy.ts @@ -3,7 +3,7 @@ import { ACCESS_TOKEN_COOKIE_NAME } from "@/lib/server/auth" const PUBLIC_ROUTES = new Set(["/login", "/signup"]) -export function middleware(req: NextRequest) { +export function proxy(req: NextRequest) { const { pathname } = req.nextUrl if ( From 27fae77f1cc8331423a743969be3183b7b2acb84 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 14:46:25 -0600 Subject: [PATCH 13/37] Streamline auth redirects and switch Keycloak hostname to auth --- .github/workflows/deploy.yml | 2 +- frontend/.env.example | 4 +- frontend/app/api/auth/keycloak/start/route.ts | 65 ++++++++++++++++- frontend/app/login/page.tsx | 71 ++++++++----------- frontend/app/signup/page.tsx | 35 +-------- 5 files changed, 96 insertions(+), 81 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5e0829e7..6cc7ca94 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,7 +19,7 @@ on: env: ACR_NAME: azopscrewacr2dovm8 ACR_SERVER: azopscrewacr2dovm8.azurecr.io - KEYCLOAK_AUTHORITY: https://aus.aoc-app.com/realms/azureopscrew + KEYCLOAK_AUTHORITY: https://auth.aoc-app.com/realms/azureopscrew KEYCLOAK_CLIENT_ID: azureopscrew-frontend jobs: diff --git a/frontend/.env.example b/frontend/.env.example index cffc6319..96b0bb41 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,12 +4,10 @@ BACKEND_API_URL= # Keycloak OIDC configuration (used by Next.js auth callback routes) -# Example authority: https://aus.aoc-app.com/realms/master +# Example authority: https://auth.aoc-app.com/realms/master KEYCLOAK_AUTHORITY= KEYCLOAK_CLIENT_ID= PUBLIC_APP_URL= KEYCLOAK_CALLBACK_URL= # Optional for confidential clients (leave empty for public PKCE client) KEYCLOAK_CLIENT_SECRET= -# Optional override when running behind a reverse proxy / custom frontend domain -KEYCLOAK_CALLBACK_URL= diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 22562c4d..8e27a44c 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -12,6 +12,41 @@ import { toSafeNextPath, } from "@/lib/server/keycloak" +function htmlDecode(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") +} + +function extractRegistrationUrl(loginPageHtml: string, authority: string): URL | null { + const match = loginPageHtml.match(/href="([^"]*login-actions\/registration[^"]*)"/i) + if (!match?.[1]) return null + + try { + return new URL(htmlDecode(match[1]), authority) + } catch { + return null + } +} + +function getUpstreamSetCookies(headers: Headers): string[] { + const typedHeaders = headers as Headers & { getSetCookie?: () => string[] } + if (typeof typedHeaders.getSetCookie === "function") { + return typedHeaders.getSetCookie() + } + + const single = headers.get("set-cookie") + return single ? [single] : [] +} + +function rewriteCookieDomainForAoc(cookie: string): string { + if (/;\s*domain=/i.test(cookie)) return cookie + return `${cookie}; Domain=.aoc-app.com` +} + export async function GET(req: NextRequest) { const publicOrigin = getPublicRequestOrigin(req) const config = getKeycloakWebConfig() @@ -35,14 +70,40 @@ export async function GET(req: NextRequest) { authUrl.searchParams.set("code_challenge", challenge) authUrl.searchParams.set("code_challenge_method", "S256") + let redirectUrl = authUrl + let upstreamKeycloakCookies: string[] = [] + if (mode === "signup") { - authUrl.searchParams.set("kc_action", "register") + try { + const preflight = await fetch(authUrl, { + method: "GET", + redirect: "manual", + cache: "no-store", + }) + + if (preflight.ok) { + const html = await preflight.text() + const registrationUrl = extractRegistrationUrl(html, config.authority) + if (registrationUrl) { + redirectUrl = registrationUrl + upstreamKeycloakCookies = getUpstreamSetCookies(preflight.headers) + .map(rewriteCookieDomainForAoc) + } + } + } catch { + // Fall back to the normal auth URL if the preflight bootstrap fails. + } } - const response = NextResponse.redirect(authUrl) + const response = NextResponse.redirect(redirectUrl) const cookieOptions = getTransientAuthCookieOptions() response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, state, cookieOptions) response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, verifier, cookieOptions) response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, nextPath, cookieOptions) + + for (const setCookie of upstreamKeycloakCookies) { + response.headers.append("set-cookie", setCookie) + } + return response } diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 43572099..63557bf7 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,62 +1,49 @@ -"use client" - -import { Suspense } from "react" import Link from "next/link" -import { useSearchParams } from "next/navigation" - -function toSafeNextPath(next: string | null): string { - if (!next) { - return "/" - } +import { redirect } from "next/navigation" - if (!next.startsWith("/") || next.startsWith("//")) { - return "/" - } +function toSafeNextPath(next: string | string[] | undefined): string { + const value = Array.isArray(next) ? next[0] : next + if (!value) return "/" + if (!value.startsWith("/") || value.startsWith("//")) return "/" + return value +} - return next +type LoginPageProps = { + searchParams: Promise<{ + next?: string | string[] + error?: string | string[] + }> } -function LoginPageContent() { - const searchParams = useSearchParams() - const nextPath = toSafeNextPath(searchParams.get("next")) - const keycloakError = searchParams.get("error") - const keycloakLoginHref = `/api/auth/keycloak/start?mode=login&next=${encodeURIComponent(nextPath)}` +export default async function LoginPage({ searchParams }: LoginPageProps) { + const params = await searchParams + const nextPath = toSafeNextPath(params.next) + const error = Array.isArray(params.error) ? params.error[0] : params.error + + if (!error) { + redirect(`/api/auth/keycloak/start?mode=login&next=${encodeURIComponent(nextPath)}`) + } return (
-
-

Login

+

Sign in failed

+

{error}

+
- Sign up + Try again -
- -
- Continue with Sign In + Sign up -

- Sign in is handled securely by Keycloak (OIDC + PKCE). -

- - {keycloakError &&

{keycloakError}

}
) } - -export default function LoginPage() { - return ( - - - - ) -} diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 0d3c869a..317068fc 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,36 +1,5 @@ -"use client" - -import Link from "next/link" +import { redirect } from "next/navigation" export default function SignupPage() { - const keycloakSignupHref = "/api/auth/keycloak/start?mode=signup" - - return ( -
-
-
-

Sign up

- - Sign in - -
- -
- - Continue with Sign Up - - -
- Registration is handled only through Keycloak. -
-
-
-
- ) + redirect("/api/auth/keycloak/start?mode=signup") } From 103b864a438ccf2cf8c4b4b4fdfdd59dd090e890 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 14:58:13 -0600 Subject: [PATCH 14/37] Limit signup bootstrap cookie relay to aoc domains --- frontend/app/api/auth/keycloak/start/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 8e27a44c..852e5356 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -42,8 +42,10 @@ function getUpstreamSetCookies(headers: Headers): string[] { return single ? [single] : [] } -function rewriteCookieDomainForAoc(cookie: string): string { +function rewriteCookieDomainForAoc(cookie: string, publicOrigin: string): string { if (/;\s*domain=/i.test(cookie)) return cookie + const hostname = new URL(publicOrigin).hostname + if (!hostname.endsWith(".aoc-app.com")) return cookie return `${cookie}; Domain=.aoc-app.com` } @@ -87,7 +89,7 @@ export async function GET(req: NextRequest) { if (registrationUrl) { redirectUrl = registrationUrl upstreamKeycloakCookies = getUpstreamSetCookies(preflight.headers) - .map(rewriteCookieDomainForAoc) + .map((cookie) => rewriteCookieDomainForAoc(cookie, publicOrigin)) } } } catch { From 9a6c8a2705d07b7bf7e87323b3ab6b7fc42ddc83 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 15:48:55 -0600 Subject: [PATCH 15/37] Map Keycloak realm by deploy environment --- .github/workflows/deploy.yml | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6cc7ca94..c17f74c5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,7 +19,7 @@ on: env: ACR_NAME: azopscrewacr2dovm8 ACR_SERVER: azopscrewacr2dovm8.azurecr.io - KEYCLOAK_AUTHORITY: https://auth.aoc-app.com/realms/azureopscrew + KEYCLOAK_BASE_URL: https://auth.aoc-app.com KEYCLOAK_CLIENT_ID: azureopscrew-frontend jobs: @@ -30,6 +30,8 @@ jobs: env_name: ${{ steps.env.outputs.name }} resource_group: ${{ steps.env.outputs.rg }} frontend_public_url: ${{ steps.env.outputs.frontend_public_url }} + keycloak_realm: ${{ steps.env.outputs.keycloak_realm }} + keycloak_authority: ${{ steps.env.outputs.keycloak_authority }} image_tag: ${{ github.sha }} steps: @@ -57,12 +59,33 @@ jobs: ;; esac + case "$ENV_NAME" in + dev) + KEYCLOAK_REALM="azureopscrew-dev" + ;; + test1) + KEYCLOAK_REALM="azureopscrew-test1" + ;; + prod) + KEYCLOAK_REALM="azureopscrew-prod" + ;; + *) + echo "Unsupported environment for Keycloak realm mapping: $ENV_NAME" >&2 + exit 1 + ;; + esac + + KEYCLOAK_AUTHORITY="${{ env.KEYCLOAK_BASE_URL }}/realms/${KEYCLOAK_REALM}" + echo "name=${ENV_NAME}" >> $GITHUB_OUTPUT echo "rg=${RESOURCE_GROUP}" >> $GITHUB_OUTPUT echo "frontend_public_url=${FRONTEND_PUBLIC_URL}" >> $GITHUB_OUTPUT + echo "keycloak_realm=${KEYCLOAK_REALM}" >> $GITHUB_OUTPUT + echo "keycloak_authority=${KEYCLOAK_AUTHORITY}" >> $GITHUB_OUTPUT echo "🎯 Environment: ${ENV_NAME}" echo "🎯 Resource Group: ${RESOURCE_GROUP}" echo "🎯 Frontend Public URL: ${FRONTEND_PUBLIC_URL}" + echo "🎯 Keycloak Realm: ${KEYCLOAK_REALM}" echo "🚀 Deploy enabled: ${{ github.event.inputs.deploy }}" - name: Azure Login @@ -137,7 +160,7 @@ jobs: Brevo__SenderEmail=${{ secrets.BREVO_SENDER_EMAIL }} \ Brevo__SenderName="${{ secrets.BREVO_SENDER_NAME }}" \ KeycloakOidc__Enabled=true \ - KeycloakOidc__Authority=${{ env.KEYCLOAK_AUTHORITY }} \ + KeycloakOidc__Authority=${{ needs.build.outputs.keycloak_authority }} \ KeycloakOidc__ClientId=${{ env.KEYCLOAK_CLIENT_ID }} \ KeycloakOidc__RequireVerifiedEmail=true echo "✅ Backend deployed" @@ -150,7 +173,7 @@ jobs: --resource-group ${{ needs.build.outputs.resource_group }} \ --image ${{ env.ACR_SERVER }}/frontend:${{ needs.build.outputs.image_tag }} \ --set-env-vars \ - KEYCLOAK_AUTHORITY=${{ env.KEYCLOAK_AUTHORITY }} \ + KEYCLOAK_AUTHORITY=${{ needs.build.outputs.keycloak_authority }} \ KEYCLOAK_CLIENT_ID=${{ env.KEYCLOAK_CLIENT_ID }} \ PUBLIC_APP_URL=${{ needs.build.outputs.frontend_public_url }} \ KEYCLOAK_CALLBACK_URL=${{ needs.build.outputs.frontend_public_url }}/api/auth/keycloak/callback From 7ef8452707e1270dbd12c878bb5e40a752df55dd Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 18:16:04 -0600 Subject: [PATCH 16/37] Filter Humans list to Keycloak-linked users --- backend/src/Api/Endpoints/UsersEndpoints.cs | 7 ++++++- frontend/lib/humans.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/Api/Endpoints/UsersEndpoints.cs b/backend/src/Api/Endpoints/UsersEndpoints.cs index 23041924..a03106df 100644 --- a/backend/src/Api/Endpoints/UsersEndpoints.cs +++ b/backend/src/Api/Endpoints/UsersEndpoints.cs @@ -9,6 +9,7 @@ public static class UsersEndpoints { private static readonly TimeSpan OnlineWindow = TimeSpan.FromMinutes(5); private static readonly TimeSpan PresenceWriteThrottle = TimeSpan.FromMinutes(1); + private const string ExternalIdentityProviderKeycloak = "keycloak"; public static void MapUsersEndpoints(this IEndpointRouteBuilder routeBuilder) { @@ -37,9 +38,13 @@ public static void MapUsersEndpoints(this IEndpointRouteBuilder routeBuilder) await context.SaveChangesAsync(cancellationToken); } + var keycloakLinkedUserIds = context.UserExternalIdentities + .Where(x => x.Provider == ExternalIdentityProviderKeycloak) + .Select(x => x.UserId); + var users = await context.Users .AsNoTracking() - .Where(u => u.IsActive) + .Where(u => u.IsActive && keycloakLinkedUserIds.Contains(u.Id)) .OrderBy(u => u.DisplayName) .Select(u => new UserPresenceDto( u.Id, diff --git a/frontend/lib/humans.ts b/frontend/lib/humans.ts index 061bfe86..9d444155 100644 --- a/frontend/lib/humans.ts +++ b/frontend/lib/humans.ts @@ -9,7 +9,7 @@ export interface HumanMember { } const HUMAN_ID_PREFIX = "human:" -const HUMANS_CACHE_KEY = "aoc_humans_cache_v1" +const HUMANS_CACHE_KEY = "aoc_humans_cache_v2" export function toHumanCardId(userId: number): string { return `${HUMAN_ID_PREFIX}${userId}` From 3c1c1a6943928a6d82c88d7ab220a56b2f2a090b Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 19:36:23 -0600 Subject: [PATCH 17/37] Add Keycloak login mode toggles and Entra SSO flags --- .github/workflows/deploy.yml | 25 ++++++++++++- frontend/.env.example | 4 +++ frontend/app/api/auth/keycloak/start/route.ts | 16 +++++++++ frontend/app/login/page.tsx | 16 +++++---- frontend/app/signup/page.tsx | 5 +++ frontend/lib/server/keycloak.ts | 36 +++++++++++++++++++ 6 files changed, 95 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c17f74c5..d6b0cc0e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,9 @@ jobs: frontend_public_url: ${{ steps.env.outputs.frontend_public_url }} keycloak_realm: ${{ steps.env.outputs.keycloak_realm }} keycloak_authority: ${{ steps.env.outputs.keycloak_authority }} + keycloak_local_login_enabled: ${{ steps.env.outputs.keycloak_local_login_enabled }} + keycloak_local_signup_enabled: ${{ steps.env.outputs.keycloak_local_signup_enabled }} + keycloak_entra_sso_enabled: ${{ steps.env.outputs.keycloak_entra_sso_enabled }} image_tag: ${{ github.sha }} steps: @@ -77,11 +80,27 @@ jobs: KEYCLOAK_AUTHORITY="${{ env.KEYCLOAK_BASE_URL }}/realms/${KEYCLOAK_REALM}" + case "$ENV_NAME" in + dev|test1|prod) + KEYCLOAK_LOCAL_LOGIN_ENABLED="true" + KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" + KEYCLOAK_ENTRA_SSO_ENABLED="true" + ;; + *) + KEYCLOAK_LOCAL_LOGIN_ENABLED="true" + KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" + KEYCLOAK_ENTRA_SSO_ENABLED="false" + ;; + esac + echo "name=${ENV_NAME}" >> $GITHUB_OUTPUT echo "rg=${RESOURCE_GROUP}" >> $GITHUB_OUTPUT echo "frontend_public_url=${FRONTEND_PUBLIC_URL}" >> $GITHUB_OUTPUT echo "keycloak_realm=${KEYCLOAK_REALM}" >> $GITHUB_OUTPUT echo "keycloak_authority=${KEYCLOAK_AUTHORITY}" >> $GITHUB_OUTPUT + echo "keycloak_local_login_enabled=${KEYCLOAK_LOCAL_LOGIN_ENABLED}" >> $GITHUB_OUTPUT + echo "keycloak_local_signup_enabled=${KEYCLOAK_LOCAL_SIGNUP_ENABLED}" >> $GITHUB_OUTPUT + echo "keycloak_entra_sso_enabled=${KEYCLOAK_ENTRA_SSO_ENABLED}" >> $GITHUB_OUTPUT echo "🎯 Environment: ${ENV_NAME}" echo "🎯 Resource Group: ${RESOURCE_GROUP}" echo "🎯 Frontend Public URL: ${FRONTEND_PUBLIC_URL}" @@ -176,7 +195,11 @@ jobs: KEYCLOAK_AUTHORITY=${{ needs.build.outputs.keycloak_authority }} \ KEYCLOAK_CLIENT_ID=${{ env.KEYCLOAK_CLIENT_ID }} \ PUBLIC_APP_URL=${{ needs.build.outputs.frontend_public_url }} \ - KEYCLOAK_CALLBACK_URL=${{ needs.build.outputs.frontend_public_url }}/api/auth/keycloak/callback + KEYCLOAK_CALLBACK_URL=${{ needs.build.outputs.frontend_public_url }}/api/auth/keycloak/callback \ + KEYCLOAK_LOCAL_LOGIN_ENABLED=${{ needs.build.outputs.keycloak_local_login_enabled }} \ + KEYCLOAK_LOCAL_SIGNUP_ENABLED=${{ needs.build.outputs.keycloak_local_signup_enabled }} \ + KEYCLOAK_ENTRA_SSO_ENABLED=${{ needs.build.outputs.keycloak_entra_sso_enabled }} \ + KEYCLOAK_ENTRA_IDP_HINT=entra echo "✅ Frontend deployed" - name: Deploy Summary diff --git a/frontend/.env.example b/frontend/.env.example index 96b0bb41..ac1b16a6 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -11,3 +11,7 @@ PUBLIC_APP_URL= KEYCLOAK_CALLBACK_URL= # Optional for confidential clients (leave empty for public PKCE client) KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_LOCAL_LOGIN_ENABLED=true +KEYCLOAK_LOCAL_SIGNUP_ENABLED=true +KEYCLOAK_ENTRA_SSO_ENABLED=false +KEYCLOAK_ENTRA_IDP_HINT=entra diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 852e5356..e1cce848 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -3,6 +3,7 @@ import { buildKeycloakCallbackUrl, createPkcePair, createRandomState, + getKeycloakAuthFeatureConfig, getPublicRequestOrigin, getKeycloakWebConfig, getTransientAuthCookieOptions, @@ -52,12 +53,24 @@ function rewriteCookieDomainForAoc(cookie: string, publicOrigin: string): string export async function GET(req: NextRequest) { const publicOrigin = getPublicRequestOrigin(req) const config = getKeycloakWebConfig() + const features = getKeycloakAuthFeatureConfig() if (!config) { return NextResponse.redirect(new URL("/login?error=Keycloak%20is%20not%20configured", publicOrigin)) } const mode = req.nextUrl.searchParams.get("mode") const nextPath = toSafeNextPath(req.nextUrl.searchParams.get("next")) + + if (mode === "signup" && !features.localSignupEnabled) { + return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent("Sign up is disabled")}`, publicOrigin)) + } + + if (mode !== "signup" && !features.localLoginEnabled && !features.entraSsoEnabled) { + return NextResponse.redirect( + new URL(`/login?error=${encodeURIComponent("All sign-in methods are disabled")}`, publicOrigin) + ) + } + const callbackUrl = buildKeycloakCallbackUrl(req) const state = createRandomState() @@ -71,6 +84,9 @@ export async function GET(req: NextRequest) { authUrl.searchParams.set("state", state) authUrl.searchParams.set("code_challenge", challenge) authUrl.searchParams.set("code_challenge_method", "S256") + if (mode !== "signup" && !features.localLoginEnabled && features.entraSsoEnabled) { + authUrl.searchParams.set("kc_idp_hint", features.entraIdpHint) + } let redirectUrl = authUrl let upstreamKeycloakCookies: string[] = [] diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 63557bf7..65b37acc 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link" import { redirect } from "next/navigation" +import { getKeycloakAuthFeatureConfig } from "@/lib/server/keycloak" function toSafeNextPath(next: string | string[] | undefined): string { const value = Array.isArray(next) ? next[0] : next @@ -19,6 +20,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const params = await searchParams const nextPath = toSafeNextPath(params.next) const error = Array.isArray(params.error) ? params.error[0] : params.error + const features = getKeycloakAuthFeatureConfig() if (!error) { redirect(`/api/auth/keycloak/start?mode=login&next=${encodeURIComponent(nextPath)}`) @@ -36,12 +38,14 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { > Try again - - Sign up - + {features.localSignupEnabled ? ( + + Sign up + + ) : null} diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 317068fc..f797a03d 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,5 +1,10 @@ import { redirect } from "next/navigation" +import { getKeycloakAuthFeatureConfig } from "@/lib/server/keycloak" export default function SignupPage() { + const features = getKeycloakAuthFeatureConfig() + if (!features.localSignupEnabled) { + redirect("/login") + } redirect("/api/auth/keycloak/start?mode=signup") } diff --git a/frontend/lib/server/keycloak.ts b/frontend/lib/server/keycloak.ts index fc02fb83..f085c685 100644 --- a/frontend/lib/server/keycloak.ts +++ b/frontend/lib/server/keycloak.ts @@ -11,6 +11,13 @@ export interface KeycloakWebConfig { clientSecret: string | null } +export interface KeycloakAuthFeatureConfig { + localLoginEnabled: boolean + localSignupEnabled: boolean + entraSsoEnabled: boolean + entraIdpHint: string +} + function firstHeaderValue(value: string | null): string | null { if (!value) return null const first = value.split(",")[0]?.trim() @@ -33,6 +40,35 @@ export function getKeycloakWebConfig(): KeycloakWebConfig | null { } } +function parseBooleanEnv(name: string, fallback: boolean): boolean { + const raw = process.env[name] + if (!raw) return fallback + + switch (raw.trim().toLowerCase()) { + case "1": + case "true": + case "yes": + case "on": + return true + case "0": + case "false": + case "no": + case "off": + return false + default: + return fallback + } +} + +export function getKeycloakAuthFeatureConfig(): KeycloakAuthFeatureConfig { + return { + localLoginEnabled: parseBooleanEnv("KEYCLOAK_LOCAL_LOGIN_ENABLED", true), + localSignupEnabled: parseBooleanEnv("KEYCLOAK_LOCAL_SIGNUP_ENABLED", true), + entraSsoEnabled: parseBooleanEnv("KEYCLOAK_ENTRA_SSO_ENABLED", false), + entraIdpHint: process.env.KEYCLOAK_ENTRA_IDP_HINT?.trim() || "entra", + } +} + export function buildKeycloakCallbackUrl(req: NextRequest): string { const configured = process.env.KEYCLOAK_CALLBACK_URL?.trim() if (configured) return configured From 19376f9a72751cc905e1ec6e85b028d71ee99c86 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 20:34:33 -0600 Subject: [PATCH 18/37] Fix Keycloak logout flow and persist id token hint --- .../app/api/auth/keycloak/callback/route.ts | 2 + frontend/app/api/auth/logout/route.ts | 57 ++++++++++++++++--- frontend/components/home-page-client.tsx | 5 +- frontend/lib/server/keycloak.ts | 1 + 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts index 59c15629..ee85a55c 100644 --- a/frontend/app/api/auth/keycloak/callback/route.ts +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -6,6 +6,7 @@ import { getPublicRequestOrigin, getKeycloakWebConfig, KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, + KEYCLOAK_ID_TOKEN_COOKIE_NAME, KEYCLOAK_NEXT_COOKIE_NAME, KEYCLOAK_STATE_COOKIE_NAME, toSafeNextPath, @@ -124,6 +125,7 @@ export async function GET(req: NextRequest) { const redirectUrl = new URL(nextPath, getPublicRequestOrigin(req)) const response = NextResponse.redirect(redirectUrl) response.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) + response.cookies.set(KEYCLOAK_ID_TOKEN_COOKIE_NAME, tokenData.id_token, getAuthCookieOptions()) response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts index 686f72fe..c90c8a8e 100644 --- a/frontend/app/api/auth/logout/route.ts +++ b/frontend/app/api/auth/logout/route.ts @@ -1,14 +1,53 @@ -import { NextResponse } from "next/server" -import { ACCESS_TOKEN_COOKIE_NAME } from "@/lib/server/auth" +import { NextRequest, NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME, getAuthCookieOptions } from "@/lib/server/auth" +import { + clearTransientAuthCookieOptions, + getKeycloakWebConfig, + getPublicRequestOrigin, + KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, + KEYCLOAK_ID_TOKEN_COOKIE_NAME, + KEYCLOAK_NEXT_COOKIE_NAME, + KEYCLOAK_STATE_COOKIE_NAME, +} from "@/lib/server/keycloak" + +function clearAuthCookies(response: NextResponse) { + const authCookieOptions = { ...getAuthCookieOptions(), maxAge: 0 } + const transientCookieOptions = clearTransientAuthCookieOptions() + + response.cookies.set(ACCESS_TOKEN_COOKIE_NAME, "", authCookieOptions) + response.cookies.set(KEYCLOAK_ID_TOKEN_COOKIE_NAME, "", authCookieOptions) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", transientCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", transientCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", transientCookieOptions) +} + +function buildKeycloakLogoutRedirect(req: NextRequest): URL | null { + const config = getKeycloakWebConfig() + if (!config) return null + + const logoutUrl = new URL(`${config.authority}/protocol/openid-connect/logout`) + const postLogoutRedirectUrl = new URL("/login", getPublicRequestOrigin(req)) + logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUrl.toString()) + logoutUrl.searchParams.set("client_id", config.clientId) + + const idTokenHint = req.cookies.get(KEYCLOAK_ID_TOKEN_COOKIE_NAME)?.value + if (idTokenHint) { + logoutUrl.searchParams.set("id_token_hint", idTokenHint) + } + + return logoutUrl +} + +export async function GET(req: NextRequest) { + const fallbackLoginUrl = new URL("/login", getPublicRequestOrigin(req)) + const keycloakLogoutUrl = buildKeycloakLogoutRedirect(req) + const response = NextResponse.redirect(keycloakLogoutUrl ?? fallbackLoginUrl) + clearAuthCookies(response) + return response +} export async function POST() { const response = NextResponse.json({ ok: true }) - response.cookies.set(ACCESS_TOKEN_COOKIE_NAME, "", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - path: "/", - maxAge: 0, - }) + clearAuthCookies(response) return response } diff --git a/frontend/components/home-page-client.tsx b/frontend/components/home-page-client.tsx index 1dc5300c..2092431d 100644 --- a/frontend/components/home-page-client.tsx +++ b/frontend/components/home-page-client.tsx @@ -250,10 +250,9 @@ export default function HomePageClient({ initialHumans }: HomePageClientProps) { setPendingDMMessage(message ?? null) }, []) - const handleLogout = useCallback(async () => { + const handleLogout = useCallback(() => { clearCachedHumans() - await fetch("/api/auth/logout", { method: "POST" }) - window.location.href = "/login" + window.location.href = "/api/auth/logout" }, []) return ( diff --git a/frontend/lib/server/keycloak.ts b/frontend/lib/server/keycloak.ts index f085c685..00ddf612 100644 --- a/frontend/lib/server/keycloak.ts +++ b/frontend/lib/server/keycloak.ts @@ -4,6 +4,7 @@ import { NextRequest } from "next/server" export const KEYCLOAK_STATE_COOKIE_NAME = "aoc_kc_state" export const KEYCLOAK_CODE_VERIFIER_COOKIE_NAME = "aoc_kc_code_verifier" export const KEYCLOAK_NEXT_COOKIE_NAME = "aoc_kc_next" +export const KEYCLOAK_ID_TOKEN_COOKIE_NAME = "aoc_kc_id_token" export interface KeycloakWebConfig { authority: string From 2bc32fabede1c71da7ef5bec8d71468d1e80727e Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 21:30:03 -0600 Subject: [PATCH 19/37] Harden auth redirects and add OIDC loop guard --- .../app/api/auth/keycloak/callback/route.ts | 59 ++++++++++++------- frontend/app/api/auth/keycloak/start/route.ts | 27 +++++++++ frontend/app/api/auth/logout/route.ts | 2 + frontend/lib/server/keycloak.ts | 8 +++ 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts index ee85a55c..85b7f256 100644 --- a/frontend/app/api/auth/keycloak/callback/route.ts +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -7,6 +7,7 @@ import { getKeycloakWebConfig, KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, KEYCLOAK_ID_TOKEN_COOKIE_NAME, + KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, KEYCLOAK_NEXT_COOKIE_NAME, KEYCLOAK_STATE_COOKIE_NAME, toSafeNextPath, @@ -30,8 +31,15 @@ function buildLoginRedirect(req: NextRequest, message: string) { return loginUrl } -export async function GET(req: NextRequest) { +function clearKeycloakTransientCookies(response: NextResponse) { const clearCookieOptions = clearTransientAuthCookieOptions() + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + response.cookies.set(KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, "", clearCookieOptions) +} + +export async function GET(req: NextRequest) { const config = getKeycloakWebConfig() if (!config) { return NextResponse.redirect(buildLoginRedirect(req, "Keycloak is not configured")) @@ -40,12 +48,15 @@ export async function GET(req: NextRequest) { const error = req.nextUrl.searchParams.get("error") if (error) { const errorDescription = req.nextUrl.searchParams.get("error_description") + console.warn("Keycloak callback returned error", { + error, + errorDescription, + path: req.nextUrl.pathname, + }) const response = NextResponse.redirect( buildLoginRedirect(req, errorDescription ?? error) ) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + clearKeycloakTransientCookies(response) return response } @@ -56,10 +67,16 @@ export async function GET(req: NextRequest) { const nextPath = toSafeNextPath(req.cookies.get(KEYCLOAK_NEXT_COOKIE_NAME)?.value ?? null) if (!code || !state || !expectedState || state !== expectedState || !codeVerifier) { + console.warn("Invalid Keycloak callback state", { + hasCode: Boolean(code), + hasState: Boolean(state), + hasExpectedState: Boolean(expectedState), + stateMatches: Boolean(state && expectedState && state === expectedState), + hasCodeVerifier: Boolean(codeVerifier), + userAgent: req.headers.get("user-agent"), + }) const response = NextResponse.redirect(buildLoginRedirect(req, "Invalid sign-in callback")) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + clearKeycloakTransientCookies(response) return response } @@ -88,10 +105,12 @@ export async function GET(req: NextRequest) { const tokenData = await tokenResponse.json().catch(() => ({})) if (!tokenResponse.ok || typeof tokenData?.id_token !== "string") { + console.warn("Keycloak token exchange failed", { + status: tokenResponse.status, + hasIdToken: typeof tokenData?.id_token === "string", + }) const response = NextResponse.redirect(buildLoginRedirect(req, "Keycloak sign-in failed")) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + clearKeycloakTransientCookies(response) return response } @@ -106,19 +125,19 @@ export async function GET(req: NextRequest) { if (!backendResponse.ok) { const errorMessage = typeof backendData?.error === "string" ? backendData.error : "Unable to complete sign-in" + console.warn("Backend Keycloak exchange failed", { + status: backendResponse.status, + errorMessage, + }) const response = NextResponse.redirect(buildLoginRedirect(req, errorMessage)) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + clearKeycloakTransientCookies(response) return response } const authData = backendData as BackendAuthResponse if (!authData.accessToken) { const response = NextResponse.redirect(buildLoginRedirect(req, "Invalid auth response")) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + clearKeycloakTransientCookies(response) return response } @@ -126,16 +145,12 @@ export async function GET(req: NextRequest) { const response = NextResponse.redirect(redirectUrl) response.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) response.cookies.set(KEYCLOAK_ID_TOKEN_COOKIE_NAME, tokenData.id_token, getAuthCookieOptions()) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + clearKeycloakTransientCookies(response) return response } catch (error) { console.error("Keycloak callback error:", error) const response = NextResponse.redirect(buildLoginRedirect(req, "Unable to complete sign-in")) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", clearCookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", clearCookieOptions) + clearKeycloakTransientCookies(response) return response } } diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index e1cce848..5132498f 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -8,8 +8,10 @@ import { getKeycloakWebConfig, getTransientAuthCookieOptions, KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, + KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, KEYCLOAK_NEXT_COOKIE_NAME, KEYCLOAK_STATE_COOKIE_NAME, + parseLoginAttemptCount, toSafeNextPath, } from "@/lib/server/keycloak" @@ -60,6 +62,26 @@ export async function GET(req: NextRequest) { const mode = req.nextUrl.searchParams.get("mode") const nextPath = toSafeNextPath(req.nextUrl.searchParams.get("next")) + const currentAttemptCount = parseLoginAttemptCount( + req.cookies.get(KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME)?.value + ) + + // Stop browser redirect loops (Safari/private mode or blocked cookies can trigger this) + // and surface an actionable error instead of infinite auth hops. + if (mode !== "signup" && currentAttemptCount >= 5) { + const loginUrl = new URL("/login", publicOrigin) + loginUrl.searchParams.set( + "error", + "Too many sign-in redirects. Clear site cookies and try again." + ) + + const response = NextResponse.redirect(loginUrl) + response.cookies.set(KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, "0", { + ...getTransientAuthCookieOptions(), + maxAge: 0, + }) + return response + } if (mode === "signup" && !features.localSignupEnabled) { return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent("Sign up is disabled")}`, publicOrigin)) @@ -118,6 +140,11 @@ export async function GET(req: NextRequest) { response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, state, cookieOptions) response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, verifier, cookieOptions) response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, nextPath, cookieOptions) + response.cookies.set( + KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, + String(mode === "signup" ? 0 : currentAttemptCount + 1), + cookieOptions + ) for (const setCookie of upstreamKeycloakCookies) { response.headers.append("set-cookie", setCookie) diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts index c90c8a8e..f9c7a461 100644 --- a/frontend/app/api/auth/logout/route.ts +++ b/frontend/app/api/auth/logout/route.ts @@ -6,6 +6,7 @@ import { getPublicRequestOrigin, KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, KEYCLOAK_ID_TOKEN_COOKIE_NAME, + KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, KEYCLOAK_NEXT_COOKIE_NAME, KEYCLOAK_STATE_COOKIE_NAME, } from "@/lib/server/keycloak" @@ -19,6 +20,7 @@ function clearAuthCookies(response: NextResponse) { response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, "", transientCookieOptions) response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, "", transientCookieOptions) response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, "", transientCookieOptions) + response.cookies.set(KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, "", transientCookieOptions) } function buildKeycloakLogoutRedirect(req: NextRequest): URL | null { diff --git a/frontend/lib/server/keycloak.ts b/frontend/lib/server/keycloak.ts index 00ddf612..3efe5484 100644 --- a/frontend/lib/server/keycloak.ts +++ b/frontend/lib/server/keycloak.ts @@ -5,6 +5,7 @@ export const KEYCLOAK_STATE_COOKIE_NAME = "aoc_kc_state" export const KEYCLOAK_CODE_VERIFIER_COOKIE_NAME = "aoc_kc_code_verifier" export const KEYCLOAK_NEXT_COOKIE_NAME = "aoc_kc_next" export const KEYCLOAK_ID_TOKEN_COOKIE_NAME = "aoc_kc_id_token" +export const KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME = "aoc_kc_login_attempts" export interface KeycloakWebConfig { authority: string @@ -147,3 +148,10 @@ export function clearTransientAuthCookieOptions() { maxAge: 0, } } + +export function parseLoginAttemptCount(value: string | null | undefined): number { + if (!value) return 0 + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed < 0) return 0 + return parsed +} From 07c4ca9a00e1437ebba8136d86748624a07d5f36 Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 21:38:21 -0600 Subject: [PATCH 20/37] Fix missing auth loop cookie on login redirect --- frontend/app/api/auth/keycloak/start/route.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 5132498f..405903b1 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -136,14 +136,13 @@ export async function GET(req: NextRequest) { } const response = NextResponse.redirect(redirectUrl) - const cookieOptions = getTransientAuthCookieOptions() - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, state, cookieOptions) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, verifier, cookieOptions) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, nextPath, cookieOptions) + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, state, getTransientAuthCookieOptions()) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, verifier, getTransientAuthCookieOptions()) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, nextPath, getTransientAuthCookieOptions()) response.cookies.set( KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, String(mode === "signup" ? 0 : currentAttemptCount + 1), - cookieOptions + getTransientAuthCookieOptions() ) for (const setCookie of upstreamKeycloakCookies) { From a890d24867b66bd75638dc18ed6500f267e0e23d Mon Sep 17 00:00:00 2001 From: Ilia Date: Sun, 22 Feb 2026 23:41:01 -0600 Subject: [PATCH 21/37] Set test1 to Entra-only auth mode --- .github/workflows/deploy.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d6b0cc0e..ba290bf8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -81,7 +81,12 @@ jobs: KEYCLOAK_AUTHORITY="${{ env.KEYCLOAK_BASE_URL }}/realms/${KEYCLOAK_REALM}" case "$ENV_NAME" in - dev|test1|prod) + test1) + KEYCLOAK_LOCAL_LOGIN_ENABLED="false" + KEYCLOAK_LOCAL_SIGNUP_ENABLED="false" + KEYCLOAK_ENTRA_SSO_ENABLED="true" + ;; + dev|prod) KEYCLOAK_LOCAL_LOGIN_ENABLED="true" KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" KEYCLOAK_ENTRA_SSO_ENABLED="true" From 3040701ca217b39c3743f39671824079f7c25a9c Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 00:00:04 -0600 Subject: [PATCH 22/37] Fix Keycloak login/logout redirect loops in browser auth flow --- frontend/app/api/auth/logout/route.ts | 4 +++ frontend/app/login/page.tsx | 31 +++++++++++++++------ frontend/app/signup/page.tsx | 12 ++++++-- frontend/components/auth-start-redirect.tsx | 30 ++++++++++++++++++++ 4 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 frontend/components/auth-start-redirect.tsx diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts index f9c7a461..994b4c94 100644 --- a/frontend/app/api/auth/logout/route.ts +++ b/frontend/app/api/auth/logout/route.ts @@ -29,6 +29,7 @@ function buildKeycloakLogoutRedirect(req: NextRequest): URL | null { const logoutUrl = new URL(`${config.authority}/protocol/openid-connect/logout`) const postLogoutRedirectUrl = new URL("/login", getPublicRequestOrigin(req)) + postLogoutRedirectUrl.searchParams.set("loggedOut", "1") logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUrl.toString()) logoutUrl.searchParams.set("client_id", config.clientId) @@ -42,14 +43,17 @@ function buildKeycloakLogoutRedirect(req: NextRequest): URL | null { export async function GET(req: NextRequest) { const fallbackLoginUrl = new URL("/login", getPublicRequestOrigin(req)) + fallbackLoginUrl.searchParams.set("loggedOut", "1") const keycloakLogoutUrl = buildKeycloakLogoutRedirect(req) const response = NextResponse.redirect(keycloakLogoutUrl ?? fallbackLoginUrl) + response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate") clearAuthCookies(response) return response } export async function POST() { const response = NextResponse.json({ ok: true }) + response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate") clearAuthCookies(response) return response } diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 65b37acc..94edb171 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link" -import { redirect } from "next/navigation" +import { AuthStartRedirect } from "@/components/auth-start-redirect" import { getKeycloakAuthFeatureConfig } from "@/lib/server/keycloak" function toSafeNextPath(next: string | string[] | undefined): string { @@ -13,6 +13,7 @@ type LoginPageProps = { searchParams: Promise<{ next?: string | string[] error?: string | string[] + loggedOut?: string | string[] }> } @@ -20,24 +21,36 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const params = await searchParams const nextPath = toSafeNextPath(params.next) const error = Array.isArray(params.error) ? params.error[0] : params.error + const loggedOut = Array.isArray(params.loggedOut) ? params.loggedOut[0] : params.loggedOut const features = getKeycloakAuthFeatureConfig() + const loginStartHref = `/api/auth/keycloak/start?mode=login&next=${encodeURIComponent(nextPath)}` - if (!error) { - redirect(`/api/auth/keycloak/start?mode=login&next=${encodeURIComponent(nextPath)}`) + if (!error && loggedOut !== "1") { + return ( + + ) } return (
-

Sign in failed

-

{error}

+

+ {error ? "Sign in failed" : "Signed out"} +

+

+ {error ?? "You have been signed out."} +

- - Try again - + {error ? "Try again" : "Sign in"} + {features.localSignupEnabled ? ( + ) } diff --git a/frontend/components/auth-start-redirect.tsx b/frontend/components/auth-start-redirect.tsx new file mode 100644 index 00000000..dbace583 --- /dev/null +++ b/frontend/components/auth-start-redirect.tsx @@ -0,0 +1,30 @@ +"use client" + +import { useEffect } from "react" + +interface AuthStartRedirectProps { + href: string + title: string + description: string +} + +export function AuthStartRedirect({ href, title, description }: AuthStartRedirectProps) { + useEffect(() => { + window.location.replace(href) + }, [href]) + + return ( +
+
+

{title}

+

{description}

+ + Continue + +
+
+ ) +} From 99aec0be044f49c3c4b9d04a81e7765f68af743f Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 00:12:35 -0600 Subject: [PATCH 23/37] Disable caching on auth pages and Keycloak routes --- frontend/app/api/auth/keycloak/callback/route.ts | 2 ++ frontend/app/api/auth/keycloak/start/route.ts | 2 ++ frontend/app/api/auth/logout/route.ts | 2 ++ frontend/app/login/page.tsx | 4 ++++ frontend/app/signup/page.tsx | 4 ++++ 5 files changed, 14 insertions(+) diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts index 85b7f256..701b625f 100644 --- a/frontend/app/api/auth/keycloak/callback/route.ts +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -13,6 +13,8 @@ import { toSafeNextPath, } from "@/lib/server/keycloak" +export const dynamic = "force-dynamic" + const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" interface BackendAuthResponse { diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 405903b1..950a1eee 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -15,6 +15,8 @@ import { toSafeNextPath, } from "@/lib/server/keycloak" +export const dynamic = "force-dynamic" + function htmlDecode(value: string): string { return value .replace(/&/g, "&") diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts index 994b4c94..28069fa1 100644 --- a/frontend/app/api/auth/logout/route.ts +++ b/frontend/app/api/auth/logout/route.ts @@ -11,6 +11,8 @@ import { KEYCLOAK_STATE_COOKIE_NAME, } from "@/lib/server/keycloak" +export const dynamic = "force-dynamic" + function clearAuthCookies(response: NextResponse) { const authCookieOptions = { ...getAuthCookieOptions(), maxAge: 0 } const transientCookieOptions = clearTransientAuthCookieOptions() diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 94edb171..39cae2c7 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,7 +1,10 @@ import Link from "next/link" +import { unstable_noStore as noStore } from "next/cache" import { AuthStartRedirect } from "@/components/auth-start-redirect" import { getKeycloakAuthFeatureConfig } from "@/lib/server/keycloak" +export const dynamic = "force-dynamic" + function toSafeNextPath(next: string | string[] | undefined): string { const value = Array.isArray(next) ? next[0] : next if (!value) return "/" @@ -18,6 +21,7 @@ type LoginPageProps = { } export default async function LoginPage({ searchParams }: LoginPageProps) { + noStore() const params = await searchParams const nextPath = toSafeNextPath(params.next) const error = Array.isArray(params.error) ? params.error[0] : params.error diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 30e8af3f..25f01a65 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,8 +1,12 @@ +import { unstable_noStore as noStore } from "next/cache" import { redirect } from "next/navigation" import { AuthStartRedirect } from "@/components/auth-start-redirect" import { getKeycloakAuthFeatureConfig } from "@/lib/server/keycloak" +export const dynamic = "force-dynamic" + export default function SignupPage() { + noStore() const features = getKeycloakAuthFeatureConfig() if (!features.localSignupEnabled) { redirect("/login?error=Sign%20up%20is%20disabled") From 8c95d1059240e5f98a34fcba788a54d46ced5301 Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 00:47:28 -0600 Subject: [PATCH 24/37] Bootstrap default workspace for Keycloak users --- backend/src/Api/Endpoints/AgentEndpoints.cs | 9 + backend/src/Api/Endpoints/AuthEndpoints.cs | 8 + backend/src/Api/Endpoints/ChannelEndpoints.cs | 9 + .../src/Api/Endpoints/ProviderEndpoints.cs | 9 + .../Api/Setup/Seeds/UserWorkspaceDefaults.cs | 202 ++++++++++++++++++ frontend/app/layout.tsx | 3 + 6 files changed, 240 insertions(+) create mode 100644 backend/src/Api/Setup/Seeds/UserWorkspaceDefaults.cs diff --git a/backend/src/Api/Endpoints/AgentEndpoints.cs b/backend/src/Api/Endpoints/AgentEndpoints.cs index 4e56356a..3d3fc176 100644 --- a/backend/src/Api/Endpoints/AgentEndpoints.cs +++ b/backend/src/Api/Endpoints/AgentEndpoints.cs @@ -1,9 +1,11 @@ using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints.Dtos.Agents; +using AzureOpsCrew.Api.Setup.Seeds; using AzureOpsCrew.Domain.Agents; using AzureOpsCrew.Domain.Channels; using AzureOpsCrew.Infrastructure.Db; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace AzureOpsCrew.Api.Endpoints { @@ -54,10 +56,17 @@ public static void MapAgentEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapGet("", async ( HttpContext httpContext, AzureOpsCrewContext context, + IOptions seederOptions, CancellationToken cancellationToken) => { var userId = httpContext.User.GetRequiredUserId(); + await UserWorkspaceDefaults.EnsureAsync( + context, + seederOptions.Value, + userId, + cancellationToken); + var agents = await context.Set() .Where(a => a.ClientId == userId) .OrderBy(a => a.DateCreated) diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs index 7a948dfa..5b133173 100644 --- a/backend/src/Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -3,6 +3,7 @@ using AzureOpsCrew.Api.Endpoints.Dtos.Auth; using AzureOpsCrew.Api.Endpoints.Filters; using AzureOpsCrew.Api.Settings; +using AzureOpsCrew.Api.Setup.Seeds; using AzureOpsCrew.Domain.Users; using AzureOpsCrew.Infrastructure.Db; using Microsoft.AspNetCore.Identity; @@ -348,6 +349,7 @@ await registrationEmailSender.SendRegistrationCodeAsync( AzureOpsCrewContext context, KeycloakIdTokenValidator keycloakIdTokenValidator, IOptions keycloakOidcOptions, + IOptions seederOptions, IPasswordHasher passwordHasher, JwtTokenService jwtTokenService, ILoggerFactory loggerFactory, @@ -481,6 +483,12 @@ await registrationEmailSender.SendRegistrationCodeAsync( user.MarkLogin(); await context.SaveChangesAsync(cancellationToken); + await UserWorkspaceDefaults.EnsureAsync( + context, + seederOptions.Value, + user.Id, + cancellationToken); + var token = jwtTokenService.CreateToken(user); return Results.Ok(ToAuthResponse(user, token)); }) diff --git a/backend/src/Api/Endpoints/ChannelEndpoints.cs b/backend/src/Api/Endpoints/ChannelEndpoints.cs index b4205d03..34ffa897 100644 --- a/backend/src/Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/Api/Endpoints/ChannelEndpoints.cs @@ -1,9 +1,11 @@ using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints.Dtos.Channels; +using AzureOpsCrew.Api.Setup.Seeds; using AzureOpsCrew.Domain.Agents; using AzureOpsCrew.Domain.Channels; using AzureOpsCrew.Infrastructure.Db; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace AzureOpsCrew.Api.Endpoints; @@ -106,10 +108,17 @@ public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapGet("", async ( HttpContext httpContext, AzureOpsCrewContext context, + IOptions seederOptions, CancellationToken cancellationToken) => { var userId = httpContext.User.GetRequiredUserId(); + await UserWorkspaceDefaults.EnsureAsync( + context, + seederOptions.Value, + userId, + cancellationToken); + var channels = await context.Set() .Where(c => c.ClientId == userId) .OrderBy(c => c.DateCreated) diff --git a/backend/src/Api/Endpoints/ProviderEndpoints.cs b/backend/src/Api/Endpoints/ProviderEndpoints.cs index 23f3b5f7..1c7c2636 100644 --- a/backend/src/Api/Endpoints/ProviderEndpoints.cs +++ b/backend/src/Api/Endpoints/ProviderEndpoints.cs @@ -1,10 +1,12 @@ using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints.Dtos.Providers; using AzureOpsCrew.Api.Endpoints.Filters; +using AzureOpsCrew.Api.Setup.Seeds; using AzureOpsCrew.Domain.Providers; using AzureOpsCrew.Domain.ProviderServices; using AzureOpsCrew.Infrastructure.Db; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace AzureOpsCrew.Api.Endpoints; @@ -71,10 +73,17 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) group.MapGet("", async ( HttpContext httpContext, AzureOpsCrewContext context, + IOptions seederOptions, CancellationToken cancellationToken) => { var userId = httpContext.User.GetRequiredUserId(); + await UserWorkspaceDefaults.EnsureAsync( + context, + seederOptions.Value, + userId, + cancellationToken); + var configs = await context.Set() .Where(p => p.ClientId == userId) .OrderBy(p => p.DateCreated) diff --git a/backend/src/Api/Setup/Seeds/UserWorkspaceDefaults.cs b/backend/src/Api/Setup/Seeds/UserWorkspaceDefaults.cs new file mode 100644 index 00000000..7233722c --- /dev/null +++ b/backend/src/Api/Setup/Seeds/UserWorkspaceDefaults.cs @@ -0,0 +1,202 @@ +using System.Security.Cryptography; +using System.Text; +using AzureOpsCrew.Domain.Agents; +using AzureOpsCrew.Domain.Channels; +using AzureOpsCrew.Domain.Providers; +using AzureOpsCrew.Infrastructure.Db; +using Microsoft.EntityFrameworkCore; + +namespace AzureOpsCrew.Api.Setup.Seeds; + +public static class UserWorkspaceDefaults +{ + private sealed record DefaultAgentTemplate(string Name, string ProviderAgentId, string Color, string Prompt, string Description); + + private static readonly DefaultAgentTemplate[] DefaultAgents = + [ + new( + "Manager", + "manager", + "#43b581", + "You are a Manager AI assistant. You help with planning, priorities, resource allocation, team coordination, and delivery. You think in terms of goals, milestones, risks, and stakeholder communication. Keep answers actionable and concise. When you have tools available (showPipelineStatus, showWorkItems, showResourceInfo, showDeployment, showMetrics), use them proactively to present information visually instead of plain text.", + "Helps with planning, priorities, resource allocation, team coordination, and delivery"), + new( + "Azure DevOps", + "azure-devops", + "#0078d4", + "You are an Azure DevOps expert. You help with pipelines (YAML and classic), CI/CD, Azure Repos, Boards, Artifacts, Test Plans, and release management. You know branching strategies, approvals, variable groups, service connections, and Azure DevOps REST APIs. Give concrete, step-by-step guidance when asked. When you have tools available (showPipelineStatus, showWorkItems, showResourceInfo, showDeployment, showMetrics), use them proactively to present information visually instead of plain text.", + "Expert in Azure DevOps pipelines, CI/CD, repos, boards, artifacts, and release management"), + new( + "Azure Dev", + "azure-dev", + "#00bcf2", + "You are an Azure development expert. You help with building and deploying apps on Azure: App Service, Functions, Container Apps, AKS, Azure SDKs, identity (Microsoft Entra ID), storage, messaging, and serverless. You focus on code, configuration, and best practices for Azure-native development. When you have tools available (showPipelineStatus, showWorkItems, showResourceInfo, showDeployment, showMetrics), use them proactively to present information visually instead of plain text.", + "Expert in building and deploying apps on Azure: App Service, Functions, Container Apps, AKS, and more") + ]; + + public static async Task EnsureAsync( + AzureOpsCrewContext context, + SeederOptions? seederOptions, + int clientId, + CancellationToken cancellationToken) + { + var hasChannel = await context.Set() + .AsNoTracking() + .AnyAsync(c => c.ClientId == clientId, cancellationToken); + + var hasAgent = await context.Set() + .AsNoTracking() + .AnyAsync(a => a.ClientId == clientId, cancellationToken); + + var hasProvider = await context.Set() + .AsNoTracking() + .AnyAsync(p => p.ClientId == clientId, cancellationToken); + + if (hasChannel && hasAgent && hasProvider) + return; + + var provider = await EnsureDefaultProviderAsync(context, seederOptions, clientId, cancellationToken); + var defaultAgents = await EnsureDefaultAgentsAsync(context, provider, clientId, cancellationToken); + await EnsureGeneralChannelAsync(context, clientId, defaultAgents, cancellationToken); + + if (!context.ChangeTracker.HasChanges()) + return; + + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Parallel requests can race on first-load bootstrap. IDs are deterministic, so + // a duplicate insert from another request is safe to ignore and retry via query. + context.ChangeTracker.Clear(); + } + } + + private static async Task EnsureDefaultProviderAsync( + AzureOpsCrewContext context, + SeederOptions? seederOptions, + int clientId, + CancellationToken cancellationToken) + { + var deterministicProviderId = DeterministicGuid("provider", clientId, "azure-openai"); + + var provider = await context.Set() + .SingleOrDefaultAsync( + p => p.Id == deterministicProviderId || + (p.ClientId == clientId && p.ProviderType == ProviderType.AzureFoundry && p.Name == "Azure OpenAI"), + cancellationToken); + + if (provider is not null) + return provider; + + var apiEndpoint = seederOptions?.AzureFoundrySeed?.ApiEndpoint?.Trim(); + var apiKey = seederOptions?.AzureFoundrySeed?.Key?.Trim(); + + if (string.IsNullOrWhiteSpace(apiEndpoint) || string.IsNullOrWhiteSpace(apiKey)) + return null; + + provider = new Provider( + deterministicProviderId, + clientId, + name: "Azure OpenAI", + ProviderType.AzureFoundry, + apiKey: apiKey, + apiEndpoint: apiEndpoint, + selectedModels: "[\"gpt-5-2-chat\"]", + defaultModel: "gpt-5-2-chat"); + + context.Add(provider); + return provider; + } + + private static async Task> EnsureDefaultAgentsAsync( + AzureOpsCrewContext context, + Provider? provider, + int clientId, + CancellationToken cancellationToken) + { + if (provider is null) + return []; + + var existingAgents = await context.Set() + .Where(a => a.ClientId == clientId) + .ToListAsync(cancellationToken); + + var results = new List(DefaultAgents.Length); + + foreach (var template in DefaultAgents) + { + var deterministicAgentId = DeterministicGuid("agent", clientId, template.ProviderAgentId); + var existing = existingAgents.FirstOrDefault(a => + a.Id == deterministicAgentId || a.ProviderAgentId == template.ProviderAgentId); + + if (existing is not null) + { + results.Add(existing); + continue; + } + + var agent = new Agent( + deterministicAgentId, + clientId, + new AgentInfo(template.Name, template.Prompt, "gpt-5-2-chat") + { + Description = template.Description, + AvailableTools = Array.Empty() + }, + provider.Id, + template.ProviderAgentId, + template.Color); + + context.Add(agent); + results.Add(agent); + } + + return results; + } + + private static async Task EnsureGeneralChannelAsync( + AzureOpsCrewContext context, + int clientId, + IReadOnlyList defaultAgents, + CancellationToken cancellationToken) + { + var deterministicChannelId = DeterministicGuid("channel", clientId, "general"); + + var channel = await context.Set() + .SingleOrDefaultAsync( + c => c.Id == deterministicChannelId || + (c.ClientId == clientId && c.Name == "General"), + cancellationToken); + + var defaultAgentIds = defaultAgents.Select(a => a.Id.ToString("D")).ToArray(); + + if (channel is null) + { + channel = new Channel(deterministicChannelId, clientId, "General") + { + Description = "General discussion and collaboration", + ConversationId = null, + AgentIds = defaultAgentIds, + DateCreated = DateTime.UtcNow + }; + + context.Add(channel); + return; + } + + if ((channel.AgentIds is null || channel.AgentIds.Length == 0) && defaultAgentIds.Length > 0) + channel.AgentIds = defaultAgentIds; + } + + private static Guid DeterministicGuid(string scope, int clientId, string key) + { + var input = Encoding.UTF8.GetBytes($"aoc-default:{scope}:{clientId}:{key}"); + var hash = SHA256.HashData(input); + var bytes = new byte[16]; + Array.Copy(hash, bytes, 16); + return new Guid(bytes); + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 935787be..fd31ae00 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -9,6 +9,9 @@ import "./globals.css" export const metadata: Metadata = { title: "AgentHub - AI Chat", description: "Chat with AI agents in a Discord-like interface", + icons: { + icon: "/placeholder-logo.svg", + }, } export const viewport: Viewport = { From 049638442f535f020d268007ecf1e10791c56d2e Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 01:05:30 -0600 Subject: [PATCH 25/37] Prefer Entra given/family name for Keycloak user display name --- backend/src/Api/Endpoints/AuthEndpoints.cs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs index 5b133173..6db8ede0 100644 --- a/backend/src/Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -560,6 +560,15 @@ private static AuthResponseDto ToAuthResponse(User user, AuthTokenResult token) private static string ResolveDisplayName(System.Security.Claims.ClaimsPrincipal principal, string email) { + var givenName = GetFirstClaimValue(principal, "given_name")?.Trim(); + var familyName = GetFirstClaimValue(principal, "family_name")?.Trim(); + var combinedName = string.Join( + " ", + new[] { givenName, familyName }.Where(value => !string.IsNullOrWhiteSpace(value))); + + if (!string.IsNullOrWhiteSpace(combinedName)) + return combinedName; + var fromClaims = GetFirstClaimValue( principal, "name", @@ -567,7 +576,17 @@ private static string ResolveDisplayName(System.Security.Claims.ClaimsPrincipal "preferred_username"); if (!string.IsNullOrWhiteSpace(fromClaims)) - return fromClaims.Trim(); + { + var candidate = fromClaims.Trim(); + + // Keycloak first-login/broker flows can produce generic placeholders such as "User". + // Prefer the email local part over placeholders so the UI doesn't show a useless label. + if (!string.Equals(candidate, "User", StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate, "Human", StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } var atIndex = email.IndexOf('@'); return atIndex > 0 ? email[..atIndex] : email; From 4099f11c5f0cd037dab3b27a8f2a96609c83c36a Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 01:26:37 -0600 Subject: [PATCH 26/37] Fix current user name in member panel and add CopilotKit info routes --- .../api/copilotkit/[agentId]/info/route.ts | 41 +++++++++++++++++++ .../app/api/copilotkit/[agentId]/route.ts | 28 ++++++++++--- frontend/app/api/copilotkit/info/route.ts | 37 +++++++++++++++++ frontend/app/api/copilotkit/route.ts | 22 +++++++--- frontend/components/member-list.tsx | 6 ++- 5 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 frontend/app/api/copilotkit/[agentId]/info/route.ts create mode 100644 frontend/app/api/copilotkit/info/route.ts diff --git a/frontend/app/api/copilotkit/[agentId]/info/route.ts b/frontend/app/api/copilotkit/[agentId]/info/route.ts new file mode 100644 index 00000000..f351b107 --- /dev/null +++ b/frontend/app/api/copilotkit/[agentId]/info/route.ts @@ -0,0 +1,41 @@ +import { HttpAgent } from "@ag-ui/client" +import { + CopilotRuntime, + ExperimentalEmptyAdapter, + copilotRuntimeNextJSAppRouterEndpoint, +} from "@copilotkit/runtime" +import { NextRequest } from "next/server" +import { getAccessToken } from "@/lib/server/auth" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ agentId: string }> } +) { + const token = getAccessToken(req) + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } + + const { agentId } = await params + const aguiAgent = new HttpAgent({ + url: `${BACKEND_API_URL}/api/agents/${agentId}/agui`, + headers: { Authorization: `Bearer ${token}` }, + }) + + const runtime = new CopilotRuntime({ + agents: { aguiAgent } as any, + }) + + const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ + runtime, + serviceAdapter: new ExperimentalEmptyAdapter(), + endpoint: `/api/copilotkit/${agentId}`, + }) + + return handleRequest(req) +} diff --git a/frontend/app/api/copilotkit/[agentId]/route.ts b/frontend/app/api/copilotkit/[agentId]/route.ts index 43d58475..4e1b5d9c 100644 --- a/frontend/app/api/copilotkit/[agentId]/route.ts +++ b/frontend/app/api/copilotkit/[agentId]/route.ts @@ -11,16 +11,20 @@ import { getAccessToken } from "@/lib/server/auth" // The /api/agents/{id}/agui endpoint is for single agent invocation const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -export async function POST( +function unauthorized() { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) +} + +async function handleCopilotRequest( req: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { const token = getAccessToken(req) if (!token) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }) + return unauthorized() } const { agentId } = await params @@ -39,3 +43,17 @@ export async function POST( }) return handleRequest(req) } + +export async function GET( + req: NextRequest, + context: { params: Promise<{ agentId: string }> } +) { + return handleCopilotRequest(req, context) +} + +export async function POST( + req: NextRequest, + context: { params: Promise<{ agentId: string }> } +) { + return handleCopilotRequest(req, context) +} diff --git a/frontend/app/api/copilotkit/info/route.ts b/frontend/app/api/copilotkit/info/route.ts new file mode 100644 index 00000000..debd1f55 --- /dev/null +++ b/frontend/app/api/copilotkit/info/route.ts @@ -0,0 +1,37 @@ +import { HttpAgent } from "@ag-ui/client" +import { + CopilotRuntime, + ExperimentalEmptyAdapter, + copilotRuntimeNextJSAppRouterEndpoint, +} from "@copilotkit/runtime" +import { NextRequest } from "next/server" +import { getAccessToken } from "@/lib/server/auth" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +export async function GET(req: NextRequest) { + const token = getAccessToken(req) + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } + + const aguiAgent = new HttpAgent({ + url: `${BACKEND_API_URL}/api/agents/default/agui`, + headers: { Authorization: `Bearer ${token}` }, + }) + + const runtime = new CopilotRuntime({ + agents: { aguiAgent } as any, + }) + + const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ + runtime, + serviceAdapter: new ExperimentalEmptyAdapter(), + endpoint: "/api/copilotkit", + }) + + return handleRequest(req) +} diff --git a/frontend/app/api/copilotkit/route.ts b/frontend/app/api/copilotkit/route.ts index bce05355..01ed798b 100644 --- a/frontend/app/api/copilotkit/route.ts +++ b/frontend/app/api/copilotkit/route.ts @@ -11,13 +11,17 @@ import { getAccessToken } from "@/lib/server/auth" // The /api/agents/{id}/agui endpoint is for single agent invocation const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -export async function POST(req: NextRequest) { +function unauthorized() { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) +} + +async function handleCopilotRequest(req: NextRequest) { const token = getAccessToken(req) if (!token) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }) + return unauthorized() } const aguiUrl = `${BACKEND_API_URL}/api/agents/default/agui` @@ -36,3 +40,11 @@ export async function POST(req: NextRequest) { }) return handleRequest(req) } + +export async function GET(req: NextRequest) { + return handleCopilotRequest(req) +} + +export async function POST(req: NextRequest) { + return handleCopilotRequest(req) +} diff --git a/frontend/components/member-list.tsx b/frontend/components/member-list.tsx index 23f85819..1581d261 100644 --- a/frontend/components/member-list.tsx +++ b/frontend/components/member-list.tsx @@ -217,7 +217,11 @@ export function MemberList({ !query || agent.name.toLowerCase().includes(query) const filteredWorking = workingAgents.filter(matchesSearch) const filteredAvailable = availableAgents.filter(matchesSearch) - const currentUserName = displayName || "You" + const currentHuman = humans.find((h) => h.isCurrentUser) + const currentUserName = + currentHuman?.name?.trim() || + displayName?.trim() || + "You" const filteredHumans = humans.filter((h) => { if (!query) return true const name = h.isCurrentUser ? currentUserName : h.name From 69325880a10ceeb1482b72dff31d8d16c90eabdc Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 01:37:36 -0600 Subject: [PATCH 27/37] Fix CopilotKit info routes for single-endpoint runtime --- .../app/api/copilotkit/[agentId]/info/route.ts | 16 +++++++++++++++- frontend/app/api/copilotkit/info/route.ts | 13 ++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/frontend/app/api/copilotkit/[agentId]/info/route.ts b/frontend/app/api/copilotkit/[agentId]/info/route.ts index f351b107..bb63d041 100644 --- a/frontend/app/api/copilotkit/[agentId]/info/route.ts +++ b/frontend/app/api/copilotkit/[agentId]/info/route.ts @@ -37,5 +37,19 @@ export async function GET( endpoint: `/api/copilotkit/${agentId}`, }) - return handleRequest(req) + const headers = new Headers(req.headers) + headers.set("content-type", "application/json") + headers.delete("content-length") + + // CopilotKit single-route runtime expects POST envelopes like { method: "info" }. + const runtimeInfoRequest = new Request( + new URL(`/api/copilotkit/${agentId}`, req.url), + { + method: "POST", + headers, + body: JSON.stringify({ method: "info" }), + } + ) + + return handleRequest(runtimeInfoRequest as NextRequest) } diff --git a/frontend/app/api/copilotkit/info/route.ts b/frontend/app/api/copilotkit/info/route.ts index debd1f55..6be60b7c 100644 --- a/frontend/app/api/copilotkit/info/route.ts +++ b/frontend/app/api/copilotkit/info/route.ts @@ -33,5 +33,16 @@ export async function GET(req: NextRequest) { endpoint: "/api/copilotkit", }) - return handleRequest(req) + const headers = new Headers(req.headers) + headers.set("content-type", "application/json") + headers.delete("content-length") + + // CopilotKit single-route runtime expects POST envelopes like { method: "info" }. + const runtimeInfoRequest = new Request(new URL("/api/copilotkit", req.url), { + method: "POST", + headers, + body: JSON.stringify({ method: "info" }), + }) + + return handleRequest(runtimeInfoRequest as NextRequest) } From 1e31d3781ac2432c5dce10c7f9fe296dcd5be6e1 Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 01:44:11 -0600 Subject: [PATCH 28/37] Proxy CopilotKit info routes to single-route runtime --- .../api/copilotkit/[agentId]/info/route.ts | 53 ++++--------------- frontend/app/api/copilotkit/info/route.ts | 42 +++------------ 2 files changed, 18 insertions(+), 77 deletions(-) diff --git a/frontend/app/api/copilotkit/[agentId]/info/route.ts b/frontend/app/api/copilotkit/[agentId]/info/route.ts index bb63d041..911702f8 100644 --- a/frontend/app/api/copilotkit/[agentId]/info/route.ts +++ b/frontend/app/api/copilotkit/[agentId]/info/route.ts @@ -1,55 +1,24 @@ -import { HttpAgent } from "@ag-ui/client" -import { - CopilotRuntime, - ExperimentalEmptyAdapter, - copilotRuntimeNextJSAppRouterEndpoint, -} from "@copilotkit/runtime" import { NextRequest } from "next/server" -import { getAccessToken } from "@/lib/server/auth" - -const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" export async function GET( req: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { - const token = getAccessToken(req) - if (!token) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }) - } - const { agentId } = await params - const aguiAgent = new HttpAgent({ - url: `${BACKEND_API_URL}/api/agents/${agentId}/agui`, - headers: { Authorization: `Bearer ${token}` }, - }) - - const runtime = new CopilotRuntime({ - agents: { aguiAgent } as any, - }) - - const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ - runtime, - serviceAdapter: new ExperimentalEmptyAdapter(), - endpoint: `/api/copilotkit/${agentId}`, - }) - const headers = new Headers(req.headers) headers.set("content-type", "application/json") headers.delete("content-length") - // CopilotKit single-route runtime expects POST envelopes like { method: "info" }. - const runtimeInfoRequest = new Request( - new URL(`/api/copilotkit/${agentId}`, req.url), - { - method: "POST", - headers, - body: JSON.stringify({ method: "info" }), - } - ) + const response = await fetch(new URL(`/api/copilotkit/${agentId}`, req.url), { + method: "POST", + headers, + body: JSON.stringify({ method: "info" }), + cache: "no-store", + }) - return handleRequest(runtimeInfoRequest as NextRequest) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) } diff --git a/frontend/app/api/copilotkit/info/route.ts b/frontend/app/api/copilotkit/info/route.ts index 6be60b7c..d5b87340 100644 --- a/frontend/app/api/copilotkit/info/route.ts +++ b/frontend/app/api/copilotkit/info/route.ts @@ -1,48 +1,20 @@ -import { HttpAgent } from "@ag-ui/client" -import { - CopilotRuntime, - ExperimentalEmptyAdapter, - copilotRuntimeNextJSAppRouterEndpoint, -} from "@copilotkit/runtime" import { NextRequest } from "next/server" -import { getAccessToken } from "@/lib/server/auth" - -const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" export async function GET(req: NextRequest) { - const token = getAccessToken(req) - if (!token) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }) - } - - const aguiAgent = new HttpAgent({ - url: `${BACKEND_API_URL}/api/agents/default/agui`, - headers: { Authorization: `Bearer ${token}` }, - }) - - const runtime = new CopilotRuntime({ - agents: { aguiAgent } as any, - }) - - const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ - runtime, - serviceAdapter: new ExperimentalEmptyAdapter(), - endpoint: "/api/copilotkit", - }) - const headers = new Headers(req.headers) headers.set("content-type", "application/json") headers.delete("content-length") - // CopilotKit single-route runtime expects POST envelopes like { method: "info" }. - const runtimeInfoRequest = new Request(new URL("/api/copilotkit", req.url), { + const response = await fetch(new URL("/api/copilotkit", req.url), { method: "POST", headers, body: JSON.stringify({ method: "info" }), + cache: "no-store", }) - return handleRequest(runtimeInfoRequest as NextRequest) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) } From 19fc05478ef6d5a28420e57c1c46d4e81308fe09 Mon Sep 17 00:00:00 2001 From: Ilia Date: Mon, 23 Feb 2026 16:16:04 -0600 Subject: [PATCH 29/37] Force reauth for Entra-only login and shorten test1 JWT TTL --- .github/workflows/deploy.yml | 6 ++++++ frontend/app/api/auth/keycloak/start/route.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba290bf8..f2d87374 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,6 +35,7 @@ jobs: keycloak_local_login_enabled: ${{ steps.env.outputs.keycloak_local_login_enabled }} keycloak_local_signup_enabled: ${{ steps.env.outputs.keycloak_local_signup_enabled }} keycloak_entra_sso_enabled: ${{ steps.env.outputs.keycloak_entra_sso_enabled }} + jwt_access_token_minutes: ${{ steps.env.outputs.jwt_access_token_minutes }} image_tag: ${{ github.sha }} steps: @@ -85,16 +86,19 @@ jobs: KEYCLOAK_LOCAL_LOGIN_ENABLED="false" KEYCLOAK_LOCAL_SIGNUP_ENABLED="false" KEYCLOAK_ENTRA_SSO_ENABLED="true" + JWT_ACCESS_TOKEN_MINUTES="15" ;; dev|prod) KEYCLOAK_LOCAL_LOGIN_ENABLED="true" KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" KEYCLOAK_ENTRA_SSO_ENABLED="true" + JWT_ACCESS_TOKEN_MINUTES="480" ;; *) KEYCLOAK_LOCAL_LOGIN_ENABLED="true" KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" KEYCLOAK_ENTRA_SSO_ENABLED="false" + JWT_ACCESS_TOKEN_MINUTES="480" ;; esac @@ -106,6 +110,7 @@ jobs: echo "keycloak_local_login_enabled=${KEYCLOAK_LOCAL_LOGIN_ENABLED}" >> $GITHUB_OUTPUT echo "keycloak_local_signup_enabled=${KEYCLOAK_LOCAL_SIGNUP_ENABLED}" >> $GITHUB_OUTPUT echo "keycloak_entra_sso_enabled=${KEYCLOAK_ENTRA_SSO_ENABLED}" >> $GITHUB_OUTPUT + echo "jwt_access_token_minutes=${JWT_ACCESS_TOKEN_MINUTES}" >> $GITHUB_OUTPUT echo "🎯 Environment: ${ENV_NAME}" echo "🎯 Resource Group: ${RESOURCE_GROUP}" echo "🎯 Frontend Public URL: ${FRONTEND_PUBLIC_URL}" @@ -175,6 +180,7 @@ jobs: Jwt__Issuer=${{ secrets.JWT_ISSUER }} \ Jwt__Audience=${{ secrets.JWT_AUDIENCE }} \ Jwt__SigningKey=${{ secrets.JWT_SIGNING_KEY }} \ + Jwt__AccessTokenMinutes=${{ needs.build.outputs.jwt_access_token_minutes }} \ EmailVerification__CodeLength=6 \ EmailVerification__CodeTtlMinutes=10 \ EmailVerification__ResendCooldownSeconds=30 \ diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 950a1eee..17cba8fe 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -109,7 +109,11 @@ export async function GET(req: NextRequest) { authUrl.searchParams.set("code_challenge", challenge) authUrl.searchParams.set("code_challenge_method", "S256") if (mode !== "signup" && !features.localLoginEnabled && features.entraSsoEnabled) { + // In Entra-only mode, force a fresh brokered auth so Keycloak doesn't silently + // reuse an existing SSO session after group membership was changed in Entra. authUrl.searchParams.set("kc_idp_hint", features.entraIdpHint) + authUrl.searchParams.set("prompt", "login") + authUrl.searchParams.set("max_age", "0") } let redirectUrl = authUrl From 1d266e0e2f99d627faf69c8dea9d60b121a55aca Mon Sep 17 00:00:00 2001 From: Ilia Date: Tue, 24 Feb 2026 01:44:29 -0600 Subject: [PATCH 30/37] Switch API auth to Keycloak access tokens only --- .github/workflows/deploy.yml | 17 - backend/.env.example | 11 - .../Api/Auth/AuthenticatedUserExtensions.cs | 6 +- backend/src/Api/Auth/JwtTokenService.cs | 64 -- .../Api/Auth/KeycloakAppUserSyncMiddleware.cs | 56 ++ .../Api/Auth/KeycloakAppUserSyncService.cs | 236 +++++++ .../src/Api/Auth/KeycloakIdTokenValidator.cs | 85 --- backend/src/Api/Endpoints/AuthEndpoints.cs | 601 +----------------- .../Extensions/ServiceCollectionExtensions.cs | 66 +- backend/src/Api/Program.cs | 3 +- backend/src/Api/Settings/JwtSettings.cs | 9 - backend/src/Api/appsettings.json | 18 - .../app/api/auth/keycloak/callback/route.ts | 52 +- frontend/lib/server/auth.ts | 4 +- 14 files changed, 388 insertions(+), 840 deletions(-) delete mode 100644 backend/src/Api/Auth/JwtTokenService.cs create mode 100644 backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs create mode 100644 backend/src/Api/Auth/KeycloakAppUserSyncService.cs delete mode 100644 backend/src/Api/Auth/KeycloakIdTokenValidator.cs delete mode 100644 backend/src/Api/Settings/JwtSettings.cs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2d87374..2f93845a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,7 +35,6 @@ jobs: keycloak_local_login_enabled: ${{ steps.env.outputs.keycloak_local_login_enabled }} keycloak_local_signup_enabled: ${{ steps.env.outputs.keycloak_local_signup_enabled }} keycloak_entra_sso_enabled: ${{ steps.env.outputs.keycloak_entra_sso_enabled }} - jwt_access_token_minutes: ${{ steps.env.outputs.jwt_access_token_minutes }} image_tag: ${{ github.sha }} steps: @@ -86,19 +85,16 @@ jobs: KEYCLOAK_LOCAL_LOGIN_ENABLED="false" KEYCLOAK_LOCAL_SIGNUP_ENABLED="false" KEYCLOAK_ENTRA_SSO_ENABLED="true" - JWT_ACCESS_TOKEN_MINUTES="15" ;; dev|prod) KEYCLOAK_LOCAL_LOGIN_ENABLED="true" KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" KEYCLOAK_ENTRA_SSO_ENABLED="true" - JWT_ACCESS_TOKEN_MINUTES="480" ;; *) KEYCLOAK_LOCAL_LOGIN_ENABLED="true" KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" KEYCLOAK_ENTRA_SSO_ENABLED="false" - JWT_ACCESS_TOKEN_MINUTES="480" ;; esac @@ -110,7 +106,6 @@ jobs: echo "keycloak_local_login_enabled=${KEYCLOAK_LOCAL_LOGIN_ENABLED}" >> $GITHUB_OUTPUT echo "keycloak_local_signup_enabled=${KEYCLOAK_LOCAL_SIGNUP_ENABLED}" >> $GITHUB_OUTPUT echo "keycloak_entra_sso_enabled=${KEYCLOAK_ENTRA_SSO_ENABLED}" >> $GITHUB_OUTPUT - echo "jwt_access_token_minutes=${JWT_ACCESS_TOKEN_MINUTES}" >> $GITHUB_OUTPUT echo "🎯 Environment: ${ENV_NAME}" echo "🎯 Resource Group: ${RESOURCE_GROUP}" echo "🎯 Frontend Public URL: ${FRONTEND_PUBLIC_URL}" @@ -177,18 +172,6 @@ jobs: --resource-group ${{ needs.build.outputs.resource_group }} \ --image ${{ env.ACR_SERVER }}/backend:${{ needs.build.outputs.image_tag }} \ --set-env-vars \ - Jwt__Issuer=${{ secrets.JWT_ISSUER }} \ - Jwt__Audience=${{ secrets.JWT_AUDIENCE }} \ - Jwt__SigningKey=${{ secrets.JWT_SIGNING_KEY }} \ - Jwt__AccessTokenMinutes=${{ needs.build.outputs.jwt_access_token_minutes }} \ - EmailVerification__CodeLength=6 \ - EmailVerification__CodeTtlMinutes=10 \ - EmailVerification__ResendCooldownSeconds=30 \ - EmailVerification__MaxVerificationAttempts=5 \ - Brevo__ApiBaseUrl=https://api.brevo.com \ - Brevo__ApiKey=${{ secrets.BREVO_API_KEY }} \ - Brevo__SenderEmail=${{ secrets.BREVO_SENDER_EMAIL }} \ - Brevo__SenderName="${{ secrets.BREVO_SENDER_NAME }}" \ KeycloakOidc__Enabled=true \ KeycloakOidc__Authority=${{ needs.build.outputs.keycloak_authority }} \ KeycloakOidc__ClientId=${{ env.KEYCLOAK_CLIENT_ID }} \ diff --git a/backend/.env.example b/backend/.env.example index 5fae3ece..e8f8ce6d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,18 +1,7 @@ AZURE_OPENAI_API_ENDPOINT= AZURE_OPENAI_API_KEY= -BREVO_API_BASE_URL=https://api.brevo.com -BREVO_API_KEY= -BREVO_SENDER_EMAIL=azureopscrew@aoc-app.com -BREVO_SENDER_NAME="Azure Ops Crew" -EMAIL_VERIFICATION_CODE_LENGTH=6 -EMAIL_VERIFICATION_CODE_TTL_MINUTES=10 -EMAIL_VERIFICATION_MAX_ATTEMPTS=5 -EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=30 KEYCLOAK_OIDC_ENABLED=false KEYCLOAK_OIDC_AUTHORITY= KEYCLOAK_OIDC_CLIENT_ID= KEYCLOAK_OIDC_REQUIRE_VERIFIED_EMAIL=true -JWT_AUDIENCE=AzureOpsCrewFrontend -JWT_ISSUER=AzureOpsCrew -JWT_SIGNING_KEY=ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars! SEEDING_ENABLED=false diff --git a/backend/src/Api/Auth/AuthenticatedUserExtensions.cs b/backend/src/Api/Auth/AuthenticatedUserExtensions.cs index 74d57917..bd778d5b 100644 --- a/backend/src/Api/Auth/AuthenticatedUserExtensions.cs +++ b/backend/src/Api/Auth/AuthenticatedUserExtensions.cs @@ -5,9 +5,13 @@ namespace AzureOpsCrew.Api.Auth; public static class AuthenticatedUserExtensions { + public const string AppUserIdClaimType = "aoc_user_id"; + public const string AppUserDisplayNameClaimType = "aoc_display_name"; + public static int GetRequiredUserId(this ClaimsPrincipal user) { - var id = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value + var id = user.FindFirst(AppUserIdClaimType)?.Value + ?? user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrWhiteSpace(id) || !int.TryParse(id, out var userId)) diff --git a/backend/src/Api/Auth/JwtTokenService.cs b/backend/src/Api/Auth/JwtTokenService.cs deleted file mode 100644 index c7690a1b..00000000 --- a/backend/src/Api/Auth/JwtTokenService.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using AzureOpsCrew.Api.Settings; -using AzureOpsCrew.Domain.Users; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; - -namespace AzureOpsCrew.Api.Auth; - -public sealed class JwtTokenService -{ - private readonly JwtSettings _settings; - private readonly byte[] _signingKey; - - public JwtTokenService(IOptions settings) - { - ArgumentNullException.ThrowIfNull(settings); - - _settings = settings.Value; - if (string.IsNullOrWhiteSpace(_settings.SigningKey)) - { - throw new ArgumentException("Jwt:SigningKey must be configured.", nameof(settings)); - } - - _signingKey = Encoding.UTF8.GetBytes(_settings.SigningKey); - if (_signingKey.Length < 16) - { - throw new ArgumentException("Jwt:SigningKey must be at least 16 bytes.", nameof(settings)); - } - } - - public AuthTokenResult CreateToken(User user) - { - var now = DateTime.UtcNow; - var expiresAtUtc = now.AddMinutes(_settings.AccessTokenMinutes); - - var claims = new List - { - new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), - new(JwtRegisteredClaimNames.Email, user.Email), - new(ClaimTypes.NameIdentifier, user.Id.ToString()), - new(ClaimTypes.Email, user.Email), - new(ClaimTypes.Name, user.DisplayName), - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), - }; - - var securityKey = new SymmetricSecurityKey(_signingKey); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - issuer: _settings.Issuer, - audience: _settings.Audience, - claims: claims, - notBefore: now, - expires: expiresAtUtc, - signingCredentials: credentials); - - var tokenValue = new JwtSecurityTokenHandler().WriteToken(token); - return new AuthTokenResult(tokenValue, expiresAtUtc); - } -} - -public sealed record AuthTokenResult(string AccessToken, DateTime ExpiresAtUtc); diff --git a/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs b/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs new file mode 100644 index 00000000..f112a89d --- /dev/null +++ b/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; + +namespace AzureOpsCrew.Api.Auth; + +public sealed class KeycloakAppUserSyncMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public KeycloakAppUserSyncMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext httpContext, KeycloakAppUserSyncService syncService) + { + var user = httpContext.User; + if (user.Identity?.IsAuthenticated != true) + { + await _next(httpContext); + return; + } + + if (user.HasClaim(c => c.Type == AuthenticatedUserExtensions.AppUserIdClaimType)) + { + await _next(httpContext); + return; + } + + var result = await syncService.EnsureUserAsync(user, httpContext.RequestAborted); + if (!result.IsSuccess) + { + httpContext.Response.StatusCode = result.StatusCode; + await httpContext.Response.WriteAsJsonAsync( + new { error = result.Error ?? "Unauthorized" }, + cancellationToken: httpContext.RequestAborted); + return; + } + + if (user.Identity is ClaimsIdentity identity) + { + identity.AddClaim(new Claim(AuthenticatedUserExtensions.AppUserIdClaimType, result.UserId.ToString())); + if (!string.IsNullOrWhiteSpace(result.DisplayName) && !user.HasClaim(c => c.Type == AuthenticatedUserExtensions.AppUserDisplayNameClaimType)) + { + identity.AddClaim(new Claim(AuthenticatedUserExtensions.AppUserDisplayNameClaimType, result.DisplayName)); + } + } + else + { + _logger.LogWarning("Authenticated principal is not a ClaimsIdentity. Local AppUser claim was not attached."); + } + + await _next(httpContext); + } +} diff --git a/backend/src/Api/Auth/KeycloakAppUserSyncService.cs b/backend/src/Api/Auth/KeycloakAppUserSyncService.cs new file mode 100644 index 00000000..018d9de5 --- /dev/null +++ b/backend/src/Api/Auth/KeycloakAppUserSyncService.cs @@ -0,0 +1,236 @@ +using System.IdentityModel.Tokens.Jwt; +using AzureOpsCrew.Api.Settings; +using AzureOpsCrew.Api.Setup.Seeds; +using AzureOpsCrew.Domain.Users; +using AzureOpsCrew.Infrastructure.Db; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace AzureOpsCrew.Api.Auth; + +public sealed class KeycloakAppUserSyncService +{ + private const string Provider = "keycloak"; + private static readonly TimeSpan PresenceWriteThrottle = TimeSpan.FromMinutes(1); + + private readonly AzureOpsCrewContext _context; + private readonly IPasswordHasher _passwordHasher; + private readonly KeycloakOidcSettings _keycloakSettings; + private readonly SeederOptions _seederOptions; + + public KeycloakAppUserSyncService( + AzureOpsCrewContext context, + IPasswordHasher passwordHasher, + IOptions keycloakOptions, + IOptions seederOptions) + { + _context = context; + _passwordHasher = passwordHasher; + _keycloakSettings = keycloakOptions.Value; + _seederOptions = seederOptions.Value; + } + + public async Task EnsureUserAsync( + System.Security.Claims.ClaimsPrincipal principal, + CancellationToken cancellationToken) + { + var providerSubject = GetFirstClaimValue( + principal, + JwtRegisteredClaimNames.Sub, + System.Security.Claims.ClaimTypes.NameIdentifier); + + if (string.IsNullOrWhiteSpace(providerSubject)) + return KeycloakAppUserSyncResult.Fail(401, "Missing subject claim in access token."); + + var email = GetFirstClaimValue( + principal, + JwtRegisteredClaimNames.Email, + System.Security.Claims.ClaimTypes.Email)?.Trim(); + + if (string.IsNullOrWhiteSpace(email)) + return KeycloakAppUserSyncResult.Fail(403, "Missing email claim in access token."); + + if (_keycloakSettings.RequireVerifiedEmail) + { + if (!TryGetBooleanClaim(principal, "email_verified", out var emailVerified)) + return KeycloakAppUserSyncResult.Fail(403, "Missing email verification claim in access token."); + + if (!emailVerified) + return KeycloakAppUserSyncResult.Fail(403, "Email address is not verified."); + } + + var normalizedEmail = NormalizeEmail(email); + var displayName = ResolveDisplayName(principal, email); + var now = DateTime.UtcNow; + var createdUser = false; + + for (var attempt = 0; attempt < 2; attempt++) + { + try + { + var linkedIdentity = await _context.UserExternalIdentities + .SingleOrDefaultAsync( + x => x.Provider == Provider && x.ProviderSubject == providerSubject, + cancellationToken); + + User? user = null; + if (linkedIdentity is not null) + { + user = await _context.Users + .SingleOrDefaultAsync(u => u.Id == linkedIdentity.UserId, cancellationToken); + + if (user is null) + { + _context.UserExternalIdentities.Remove(linkedIdentity); + await _context.SaveChangesAsync(cancellationToken); + linkedIdentity = null; + } + } + + if (user is null) + { + user = await _context.Users + .SingleOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); + + if (user is null) + { + user = new User( + email: email, + normalizedEmail: normalizedEmail, + passwordHash: string.Empty, + displayName: displayName); + + var externalOnlyPasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + user.UpdatePasswordHash(externalOnlyPasswordHash); + + _context.Users.Add(user); + await _context.SaveChangesAsync(cancellationToken); + createdUser = true; + } + } + + if (linkedIdentity is null) + { + linkedIdentity = new UserExternalIdentity(user.Id, Provider, providerSubject, email); + _context.UserExternalIdentities.Add(linkedIdentity); + } + else + { + linkedIdentity.UpdateEmail(email); + } + + if (!string.IsNullOrWhiteSpace(displayName) && + !string.Equals(user.DisplayName, displayName, StringComparison.Ordinal)) + { + user.UpdateDisplayName(displayName); + } + + if (!user.IsActive) + return KeycloakAppUserSyncResult.Fail(403, "User is deactivated."); + + if (!user.LastLoginAt.HasValue || now - user.LastLoginAt.Value >= PresenceWriteThrottle) + user.MarkLogin(); + + await _context.SaveChangesAsync(cancellationToken); + + if (createdUser) + { + await UserWorkspaceDefaults.EnsureAsync( + _context, + _seederOptions, + user.Id, + cancellationToken); + } + + return KeycloakAppUserSyncResult.Success(user.Id, user.Email, user.DisplayName); + } + catch (DbUpdateException) when (attempt == 0) + { + _context.ChangeTracker.Clear(); + createdUser = false; + } + } + + return KeycloakAppUserSyncResult.Fail(503, "Unable to synchronize user profile."); + } + + private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant(); + + private static string ResolveDisplayName(System.Security.Claims.ClaimsPrincipal principal, string email) + { + var givenName = GetFirstClaimValue(principal, "given_name")?.Trim(); + var familyName = GetFirstClaimValue(principal, "family_name")?.Trim(); + var combinedName = string.Join( + " ", + new[] { givenName, familyName }.Where(value => !string.IsNullOrWhiteSpace(value))); + + if (!string.IsNullOrWhiteSpace(combinedName)) + return combinedName; + + var fromClaims = GetFirstClaimValue( + principal, + "name", + System.Security.Claims.ClaimTypes.Name, + "preferred_username"); + + if (!string.IsNullOrWhiteSpace(fromClaims)) + { + var candidate = fromClaims.Trim(); + if (!string.Equals(candidate, "User", StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate, "Human", StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + + var atIndex = email.IndexOf('@'); + return atIndex > 0 ? email[..atIndex] : email; + } + + private static string? GetFirstClaimValue(System.Security.Claims.ClaimsPrincipal principal, params string[] claimTypes) + { + foreach (var claimType in claimTypes) + { + var value = principal.FindFirst(claimType)?.Value; + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + return null; + } + + private static bool TryGetBooleanClaim(System.Security.Claims.ClaimsPrincipal principal, string claimType, out bool value) + { + var raw = principal.FindFirst(claimType)?.Value; + if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1") + { + value = true; + return true; + } + + if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase) || raw == "0") + { + value = false; + return true; + } + + value = false; + return false; + } +} + +public sealed record KeycloakAppUserSyncResult( + bool IsSuccess, + int StatusCode, + int UserId, + string? Email, + string? DisplayName, + string? Error) +{ + public static KeycloakAppUserSyncResult Success(int userId, string email, string displayName) => + new(true, StatusCodes.Status200OK, userId, email, displayName, null); + + public static KeycloakAppUserSyncResult Fail(int statusCode, string error) => + new(false, statusCode, 0, null, null, error); +} diff --git a/backend/src/Api/Auth/KeycloakIdTokenValidator.cs b/backend/src/Api/Auth/KeycloakIdTokenValidator.cs deleted file mode 100644 index 29163738..00000000 --- a/backend/src/Api/Auth/KeycloakIdTokenValidator.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using AzureOpsCrew.Api.Settings; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; - -namespace AzureOpsCrew.Api.Auth; - -public sealed class KeycloakIdTokenValidator -{ - private readonly KeycloakOidcSettings _settings; - private readonly ConfigurationManager? _configurationManager; - private readonly JwtSecurityTokenHandler _tokenHandler = new(); - - public KeycloakIdTokenValidator(IOptions options) - { - ArgumentNullException.ThrowIfNull(options); - - _settings = options.Value; - if (!_settings.Enabled) - return; - - if (string.IsNullOrWhiteSpace(_settings.Authority)) - throw new InvalidOperationException("KeycloakOidc__Authority is required when KeycloakOidc__Enabled=true."); - - if (string.IsNullOrWhiteSpace(_settings.ClientId)) - throw new InvalidOperationException("KeycloakOidc__ClientId is required when KeycloakOidc__Enabled=true."); - - var authority = _settings.Authority.TrimEnd('/'); - var metadataAddress = $"{authority}/.well-known/openid-configuration"; - var retriever = new HttpDocumentRetriever { RequireHttps = true }; - - _configurationManager = new ConfigurationManager( - metadataAddress, - new OpenIdConnectConfigurationRetriever(), - retriever); - } - - public bool IsEnabled => _settings.Enabled; - - public async Task ValidateIdTokenAsync(string idToken, CancellationToken cancellationToken) - { - if (!_settings.Enabled || _configurationManager is null) - throw new InvalidOperationException("Keycloak OIDC validation is not enabled."); - - if (string.IsNullOrWhiteSpace(idToken)) - throw new SecurityTokenException("ID token is required."); - - var configuration = await _configurationManager.GetConfigurationAsync(cancellationToken); - return Validate(idToken, configuration); - } - - private ClaimsPrincipal Validate(string idToken, OpenIdConnectConfiguration configuration) - { - var validationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = _settings.Authority.TrimEnd('/'), - ValidateAudience = true, - ValidAudience = _settings.ClientId, - ValidateIssuerSigningKey = true, - IssuerSigningKeys = configuration.SigningKeys, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1), - RequireSignedTokens = true, - NameClaimType = "name" - }; - - try - { - return _tokenHandler.ValidateToken(idToken, validationParameters, out _); - } - catch (ArgumentException ex) - { - throw new SecurityTokenException("Invalid ID token format.", ex); - } - catch (SecurityTokenSignatureKeyNotFoundException) - { - _configurationManager!.RequestRefresh(); - throw; - } - } -} diff --git a/backend/src/Api/Endpoints/AuthEndpoints.cs b/backend/src/Api/Endpoints/AuthEndpoints.cs index 6db8ede0..a5b07bc0 100644 --- a/backend/src/Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -1,17 +1,7 @@ using AzureOpsCrew.Api.Auth; -using AzureOpsCrew.Api.Email; using AzureOpsCrew.Api.Endpoints.Dtos.Auth; -using AzureOpsCrew.Api.Endpoints.Filters; -using AzureOpsCrew.Api.Settings; -using AzureOpsCrew.Api.Setup.Seeds; -using AzureOpsCrew.Domain.Users; using AzureOpsCrew.Infrastructure.Db; -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.Security.Cryptography; -using System.IdentityModel.Tokens.Jwt; namespace AzureOpsCrew.Api.Endpoints; @@ -19,486 +9,31 @@ public static class AuthEndpoints { public static void MapAuthEndpoints(this IEndpointRouteBuilder routeBuilder) { - var legacyPasswordAuthEnabled = false; var group = routeBuilder.MapGroup("/api/auth") .WithTags("Auth"); - group.MapPost("/register", async ( - RegisterRequestDto body, - AzureOpsCrewContext context, - IPasswordHasher pendingRegistrationHasher, - IRegistrationEmailSender registrationEmailSender, - IOptions emailVerificationOptions, - CancellationToken cancellationToken) => - { - if (!legacyPasswordAuthEnabled) - return LegacyPasswordAuthDisabled(); - - var settings = emailVerificationOptions.Value; - var now = DateTime.UtcNow; - var normalizedEmail = NormalizeEmail(body.Email); - var email = body.Email.Trim(); - - var exists = await context.Users - .AnyAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); - - if (exists) - return Results.Conflict(new { error = "Email is already registered." }); - - var pendingRegistration = await context.PendingRegistrations - .SingleOrDefaultAsync(p => p.NormalizedEmail == normalizedEmail, cancellationToken); - - if (pendingRegistration is not null) - { - var remainingCooldown = GetRemainingCooldownSeconds( - now, - pendingRegistration.VerificationCodeSentAt, - settings.ResendCooldownSeconds); - - if (remainingCooldown > 0) - { - return Results.Json( - new - { - error = $"Please wait {remainingCooldown} seconds before requesting another code.", - retryAfterSeconds = remainingCooldown - }, - statusCode: StatusCodes.Status429TooManyRequests); - } - } - - if (pendingRegistration is null) - { - pendingRegistration = new PendingRegistration(email, normalizedEmail); - context.PendingRegistrations.Add(pendingRegistration); - } - - var displayName = string.IsNullOrWhiteSpace(body.DisplayName) - ? email - : body.DisplayName.Trim(); - - var verificationCode = GenerateVerificationCode(settings.CodeLength); - var passwordHash = pendingRegistrationHasher.HashPassword(pendingRegistration, body.Password); - var verificationCodeHash = pendingRegistrationHasher.HashPassword(pendingRegistration, verificationCode); - var expiresAtUtc = now.AddMinutes(settings.CodeTtlMinutes); - - pendingRegistration.Refresh( - email, - displayName, - passwordHash, - verificationCodeHash, - expiresAtUtc, - now); - - await context.SaveChangesAsync(cancellationToken); - - try - { - await registrationEmailSender.SendRegistrationCodeAsync( - email, - verificationCode, - expiresAtUtc, - cancellationToken); - } - catch (Exception) when (cancellationToken.IsCancellationRequested is false) - { - return Results.Json( - new { error = "Unable to send verification email. Please try again." }, - statusCode: StatusCodes.Status503ServiceUnavailable); - } - - return Results.Ok( - new RegisterChallengeDto( - "Verification code sent. Check your email to continue.", - expiresAtUtc, - settings.ResendCooldownSeconds)); - }) - .AddEndpointFilter>() - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status429TooManyRequests) - .Produces(StatusCodes.Status503ServiceUnavailable) - .Produces(StatusCodes.Status409Conflict) - .AllowAnonymous(); - - group.MapPost("/register/resend", async ( - ResendRegistrationCodeRequestDto body, - AzureOpsCrewContext context, - IPasswordHasher pendingRegistrationHasher, - IRegistrationEmailSender registrationEmailSender, - IOptions emailVerificationOptions, - CancellationToken cancellationToken) => - { - if (!legacyPasswordAuthEnabled) - return LegacyPasswordAuthDisabled(); - - var settings = emailVerificationOptions.Value; - var now = DateTime.UtcNow; - var normalizedEmail = NormalizeEmail(body.Email); - - var pendingRegistration = await context.PendingRegistrations - .SingleOrDefaultAsync(p => p.NormalizedEmail == normalizedEmail, cancellationToken); - - if (pendingRegistration is null) - return Results.BadRequest(new { error = "Registration request not found. Start sign up again." }); - - var remainingCooldown = GetRemainingCooldownSeconds( - now, - pendingRegistration.VerificationCodeSentAt, - settings.ResendCooldownSeconds); - - if (remainingCooldown > 0) - { - return Results.Json( - new - { - error = $"Please wait {remainingCooldown} seconds before requesting another code.", - retryAfterSeconds = remainingCooldown - }, - statusCode: StatusCodes.Status429TooManyRequests); - } - - var verificationCode = GenerateVerificationCode(settings.CodeLength); - var verificationCodeHash = pendingRegistrationHasher.HashPassword(pendingRegistration, verificationCode); - var expiresAtUtc = now.AddMinutes(settings.CodeTtlMinutes); - - pendingRegistration.Refresh( - pendingRegistration.Email, - pendingRegistration.DisplayName, - pendingRegistration.PasswordHash, - verificationCodeHash, - expiresAtUtc, - now); - - await context.SaveChangesAsync(cancellationToken); - - try - { - await registrationEmailSender.SendRegistrationCodeAsync( - pendingRegistration.Email, - verificationCode, - expiresAtUtc, - cancellationToken); - } - catch (Exception) when (cancellationToken.IsCancellationRequested is false) - { - return Results.Json( - new { error = "Unable to send verification email. Please try again." }, - statusCode: StatusCodes.Status503ServiceUnavailable); - } - - return Results.Ok( - new RegisterChallengeDto( - "A new verification code has been sent.", - expiresAtUtc, - settings.ResendCooldownSeconds)); - }) - .AddEndpointFilter>() - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status429TooManyRequests) - .Produces(StatusCodes.Status503ServiceUnavailable) - .AllowAnonymous(); - - group.MapPost("/register/verify", async ( - VerifyRegistrationCodeRequestDto body, - AzureOpsCrewContext context, - IPasswordHasher pendingRegistrationHasher, - JwtTokenService jwtTokenService, - IOptions emailVerificationOptions, - CancellationToken cancellationToken) => - { - if (!legacyPasswordAuthEnabled) - return LegacyPasswordAuthDisabled(); - - var settings = emailVerificationOptions.Value; - var now = DateTime.UtcNow; - var normalizedEmail = NormalizeEmail(body.Email); - - var pendingRegistration = await context.PendingRegistrations - .SingleOrDefaultAsync(p => p.NormalizedEmail == normalizedEmail, cancellationToken); - - if (pendingRegistration is null) - return Results.BadRequest(new { error = "Registration request not found. Start sign up again." }); - - if (pendingRegistration.VerificationCodeExpiresAt < now) - { - context.PendingRegistrations.Remove(pendingRegistration); - await context.SaveChangesAsync(cancellationToken); - return Results.BadRequest(new { error = "Verification code expired. Request a new one." }); - } - - if (pendingRegistration.VerificationAttempts >= settings.MaxVerificationAttempts) - { - context.PendingRegistrations.Remove(pendingRegistration); - await context.SaveChangesAsync(cancellationToken); - return Results.BadRequest(new { error = "Too many invalid attempts. Start sign up again." }); - } - - var code = body.Code.Trim(); - var verificationResult = pendingRegistrationHasher.VerifyHashedPassword( - pendingRegistration, - pendingRegistration.VerificationCodeHash, - code); - - if (verificationResult == PasswordVerificationResult.Failed) - { - pendingRegistration.IncrementFailedAttempt(); - var attemptsLeft = settings.MaxVerificationAttempts - pendingRegistration.VerificationAttempts; - - if (attemptsLeft <= 0) - { - context.PendingRegistrations.Remove(pendingRegistration); - await context.SaveChangesAsync(cancellationToken); - return Results.BadRequest(new { error = "Too many invalid attempts. Start sign up again." }); - } - - await context.SaveChangesAsync(cancellationToken); - return Results.BadRequest(new { error = $"Invalid verification code. {attemptsLeft} attempt(s) left." }); - } - - var exists = await context.Users - .AnyAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); - - if (exists) - { - context.PendingRegistrations.Remove(pendingRegistration); - await context.SaveChangesAsync(cancellationToken); - return Results.Conflict(new { error = "Email is already registered." }); - } - - try - { - var user = new User( - email: pendingRegistration.Email, - normalizedEmail: pendingRegistration.NormalizedEmail, - passwordHash: pendingRegistration.PasswordHash, - displayName: pendingRegistration.DisplayName); - user.MarkLogin(); - - context.Users.Add(user); - context.PendingRegistrations.Remove(pendingRegistration); - await context.SaveChangesAsync(cancellationToken); - - var token = jwtTokenService.CreateToken(user); - return Results.Ok(ToAuthResponse(user, token)); - } - catch (DbUpdateException) - { - // Save can race with another successful verification for the same email. - context.ChangeTracker.Clear(); - - var emailAlreadyExists = await context.Users - .AsNoTracking() - .AnyAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); - - if (emailAlreadyExists) - return Results.Conflict(new { error = "Email is already registered." }); - - throw; - } - }) - .AddEndpointFilter>() - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status409Conflict) - .AllowAnonymous(); - - group.MapPost("/login", async ( - LoginRequestDto body, - AzureOpsCrewContext context, - IPasswordHasher passwordHasher, - JwtTokenService jwtTokenService, - CancellationToken cancellationToken) => - { - if (!legacyPasswordAuthEnabled) - return LegacyPasswordAuthDisabled(); - - var normalizedEmail = NormalizeEmail(body.Email); - - var user = await context.Users - .SingleOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); - - if (user is null || !user.IsActive) - return Results.Unauthorized(); - - var passwordResult = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, body.Password); - if (passwordResult == PasswordVerificationResult.Failed) - return Results.Unauthorized(); - - if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) - { - var rehash = passwordHasher.HashPassword(user, body.Password); - user.UpdatePasswordHash(rehash); - } - - user.MarkLogin(); - await context.SaveChangesAsync(cancellationToken); - - var token = jwtTokenService.CreateToken(user); - return Results.Ok(ToAuthResponse(user, token)); - }) - .AddEndpointFilter>() - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status401Unauthorized) - .AllowAnonymous(); - - group.MapPost("/keycloak/exchange", async ( - KeycloakExchangeRequestDto body, - AzureOpsCrewContext context, - KeycloakIdTokenValidator keycloakIdTokenValidator, - IOptions keycloakOidcOptions, - IOptions seederOptions, - IPasswordHasher passwordHasher, - JwtTokenService jwtTokenService, - ILoggerFactory loggerFactory, - CancellationToken cancellationToken) => - { - if (!keycloakIdTokenValidator.IsEnabled) - { - return Results.Json( - new { error = "Keycloak sign-in is not enabled." }, - statusCode: StatusCodes.Status501NotImplemented); - } - - System.Security.Claims.ClaimsPrincipal principal; - try - { - principal = await keycloakIdTokenValidator.ValidateIdTokenAsync(body.IdToken.Trim(), cancellationToken); - } - catch (SecurityTokenException) - { - return Results.Unauthorized(); - } - catch (Exception ex) when (cancellationToken.IsCancellationRequested is false) - { - loggerFactory.CreateLogger("Auth.KeycloakExchange") - .LogError(ex, "Failed to validate Keycloak ID token."); - - return Results.Json( - new { error = "Unable to validate identity token. Please try again." }, - statusCode: StatusCodes.Status503ServiceUnavailable); - } - - var providerSubject = GetFirstClaimValue( - principal, - JwtRegisteredClaimNames.Sub, - System.Security.Claims.ClaimTypes.NameIdentifier); - - if (string.IsNullOrWhiteSpace(providerSubject)) - { - return Results.BadRequest(new { error = "Missing subject claim in identity token." }); - } - - var email = GetFirstClaimValue( - principal, - JwtRegisteredClaimNames.Email, - System.Security.Claims.ClaimTypes.Email)?.Trim(); + group.MapPost("/register", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); - if (string.IsNullOrWhiteSpace(email)) - { - return Results.BadRequest(new { error = "Missing email claim in identity token." }); - } + group.MapPost("/register/resend", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); - var emailVerified = false; - if (keycloakOidcOptions.Value.RequireVerifiedEmail && !TryGetBooleanClaim(principal, "email_verified", out emailVerified)) - { - return Results.BadRequest(new { error = "Missing email verification claim in identity token." }); - } + group.MapPost("/register/verify", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); - if (keycloakOidcOptions.Value.RequireVerifiedEmail && emailVerified is false) - { - return Results.Json( - new { error = "Email address is not verified." }, - statusCode: StatusCodes.Status403Forbidden); - } + group.MapPost("/login", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); - const string provider = "keycloak"; - var normalizedEmail = NormalizeEmail(email); - var displayName = ResolveDisplayName(principal, email); - - var linkedIdentity = await context.UserExternalIdentities - .SingleOrDefaultAsync( - x => x.Provider == provider && x.ProviderSubject == providerSubject, - cancellationToken); - - User? user = null; - if (linkedIdentity is not null) - { - user = await context.Users - .SingleOrDefaultAsync(u => u.Id == linkedIdentity.UserId, cancellationToken); - - if (user is null) - { - context.UserExternalIdentities.Remove(linkedIdentity); - await context.SaveChangesAsync(cancellationToken); - linkedIdentity = null; - } - } - - if (user is null) - { - user = await context.Users - .SingleOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); - - if (user is null) - { - user = new User( - email: email, - normalizedEmail: normalizedEmail, - passwordHash: string.Empty, - displayName: displayName); - - var externalOnlyPasswordHash = passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); - user.UpdatePasswordHash(externalOnlyPasswordHash); - - context.Users.Add(user); - await context.SaveChangesAsync(cancellationToken); - } - } - - if (linkedIdentity is null) - { - linkedIdentity = new UserExternalIdentity(user.Id, provider, providerSubject, email); - context.UserExternalIdentities.Add(linkedIdentity); - } - else - { - linkedIdentity.UpdateEmail(email); - } - - if (!string.IsNullOrWhiteSpace(displayName) && !string.Equals(user.DisplayName, displayName, StringComparison.Ordinal)) - { - user.UpdateDisplayName(displayName); - } - - if (!user.IsActive) - { - return Results.Json( - new { error = "User is deactivated." }, - statusCode: StatusCodes.Status403Forbidden); - } - - user.MarkLogin(); - await context.SaveChangesAsync(cancellationToken); - - await UserWorkspaceDefaults.EnsureAsync( - context, - seederOptions.Value, - user.Id, - cancellationToken); - - var token = jwtTokenService.CreateToken(user); - return Results.Ok(ToAuthResponse(user, token)); - }) - .AddEndpointFilter>() - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status501NotImplemented) - .AllowAnonymous(); + group.MapPost("/keycloak/exchange", () => + Results.Json( + new { error = "Deprecated. Frontend must use Keycloak-issued access tokens directly." }, + statusCode: StatusCodes.Status410Gone)) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); group.MapGet("/me", async ( HttpContext httpContext, @@ -513,13 +48,6 @@ await UserWorkspaceDefaults.EnsureAsync( if (user is null) return Results.Unauthorized(); - var now = DateTime.UtcNow; - if (!user.LastLoginAt.HasValue || now - user.LastLoginAt.Value >= TimeSpan.FromMinutes(1)) - { - user.MarkLogin(); - await context.SaveChangesAsync(cancellationToken); - } - return Results.Ok(new AuthUserDto(user.Id, user.Email, user.DisplayName)); }) .Produces(StatusCodes.Status200OK) @@ -527,99 +55,8 @@ await UserWorkspaceDefaults.EnsureAsync( .RequireAuthorization(); } - private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant(); - - private static IResult LegacyPasswordAuthDisabled() => + private static IResult LegacyEmailPasswordAuthDisabled() => Results.Json( new { error = "Email/password authentication is disabled. Use Keycloak sign-in." }, statusCode: StatusCodes.Status410Gone); - - private static int GetRemainingCooldownSeconds( - DateTime nowUtc, - DateTime lastSentAtUtc, - int resendCooldownSeconds) - { - var secondsSinceLastSend = (int)(nowUtc - lastSentAtUtc).TotalSeconds; - return Math.Max(0, resendCooldownSeconds - secondsSinceLastSend); - } - - private static string GenerateVerificationCode(int codeLength) - { - var maxExclusive = (int)Math.Pow(10, codeLength); - var value = RandomNumberGenerator.GetInt32(0, maxExclusive); - return value.ToString($"D{codeLength}"); - } - - private static AuthResponseDto ToAuthResponse(User user, AuthTokenResult token) - { - return new AuthResponseDto( - token.AccessToken, - token.ExpiresAtUtc, - new AuthUserDto(user.Id, user.Email, user.DisplayName)); - } - - private static string ResolveDisplayName(System.Security.Claims.ClaimsPrincipal principal, string email) - { - var givenName = GetFirstClaimValue(principal, "given_name")?.Trim(); - var familyName = GetFirstClaimValue(principal, "family_name")?.Trim(); - var combinedName = string.Join( - " ", - new[] { givenName, familyName }.Where(value => !string.IsNullOrWhiteSpace(value))); - - if (!string.IsNullOrWhiteSpace(combinedName)) - return combinedName; - - var fromClaims = GetFirstClaimValue( - principal, - "name", - System.Security.Claims.ClaimTypes.Name, - "preferred_username"); - - if (!string.IsNullOrWhiteSpace(fromClaims)) - { - var candidate = fromClaims.Trim(); - - // Keycloak first-login/broker flows can produce generic placeholders such as "User". - // Prefer the email local part over placeholders so the UI doesn't show a useless label. - if (!string.Equals(candidate, "User", StringComparison.OrdinalIgnoreCase) && - !string.Equals(candidate, "Human", StringComparison.OrdinalIgnoreCase)) - { - return candidate; - } - } - - var atIndex = email.IndexOf('@'); - return atIndex > 0 ? email[..atIndex] : email; - } - - private static string? GetFirstClaimValue(System.Security.Claims.ClaimsPrincipal principal, params string[] claimTypes) - { - foreach (var claimType in claimTypes) - { - var value = principal.FindFirst(claimType)?.Value; - if (!string.IsNullOrWhiteSpace(value)) - return value; - } - - return null; - } - - private static bool TryGetBooleanClaim(System.Security.Claims.ClaimsPrincipal principal, string claimType, out bool value) - { - var raw = principal.FindFirst(claimType)?.Value; - if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1") - { - value = true; - return true; - } - - if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase) || raw == "0") - { - value = false; - return true; - } - - value = false; - return false; - } } diff --git a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs index f4e8d24c..ae525938 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.IdentityModel.Tokens.Jwt; using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Email; using AzureOpsCrew.Api.Settings; @@ -112,27 +112,16 @@ public static void AddProviderFacades(this IServiceCollection services) public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) { - var settings = configuration.GetSection("Jwt").Get() ?? new JwtSettings(); + var keycloak = configuration.GetSection("KeycloakOidc").Get() ?? new KeycloakOidcSettings(); - if (string.IsNullOrWhiteSpace(settings.Issuer)) - throw new InvalidOperationException("Jwt__Issuer is required."); + if (!keycloak.Enabled) + throw new InvalidOperationException("KeycloakOidc__Enabled=true is required. Backend now accepts only Keycloak-issued access tokens."); - if (string.IsNullOrWhiteSpace(settings.Audience)) - throw new InvalidOperationException("Jwt__Audience is required."); + if (string.IsNullOrWhiteSpace(keycloak.Authority)) + throw new InvalidOperationException("KeycloakOidc__Authority is required when KeycloakOidc__Enabled=true."); - if (string.IsNullOrWhiteSpace(settings.SigningKey) || settings.SigningKey.Length < 32) - throw new InvalidOperationException("Jwt__SigningKey must be at least 32 characters."); - - if (settings.AccessTokenMinutes <= 0) - throw new InvalidOperationException("Jwt__AccessTokenMinutes must be greater than zero."); - - if (settings.SigningKey.Contains("ChangeThisDevelopmentOnly", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("A real JWT signing key must be configured."); - - var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey)); - - services.Configure(configuration.GetSection("Jwt")); - services.AddOptions(); + if (string.IsNullOrWhiteSpace(keycloak.ClientId)) + throw new InvalidOperationException("KeycloakOidc__ClientId is required when KeycloakOidc__Enabled=true."); services.AddAuthentication(options => { @@ -142,22 +131,46 @@ public static void AddJwtAuthentication(this IServiceCollection services, IConfi .AddJwtBearer(options => { options.RequireHttpsMetadata = !environment.IsDevelopment(); + options.MapInboundClaims = false; + options.Authority = keycloak.Authority.TrimEnd('/'); options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, - ValidIssuer = settings.Issuer, - ValidateAudience = true, - ValidAudience = settings.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = signingKey, + ValidIssuer = keycloak.Authority.TrimEnd('/'), + ValidateAudience = false, ValidateLifetime = true, - ClockSkew = TimeSpan.FromSeconds(30) + ClockSkew = TimeSpan.FromSeconds(30), + NameClaimType = "name" + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + var principal = context.Principal; + var azp = principal?.FindFirst("azp")?.Value ?? principal?.FindFirst("client_id")?.Value; + if (!string.Equals(azp, keycloak.ClientId, StringComparison.Ordinal)) + { + context.Fail("Token was not issued to the expected client."); + return Task.CompletedTask; + } + + var jwt = context.SecurityToken as JwtSecurityToken; + var tokenType = jwt?.Header.Typ ?? principal?.FindFirst("typ")?.Value; + if (string.Equals(tokenType, "ID", StringComparison.OrdinalIgnoreCase)) + { + context.Fail("ID tokens are not accepted for API authorization."); + return Task.CompletedTask; + } + + return Task.CompletedTask; + } }; }); services.AddAuthorization(); - services.AddSingleton(); services.AddScoped, PasswordHasher>(); + services.AddScoped(); } public static void AddEmailVerification(this IServiceCollection services, IConfiguration configuration) @@ -230,6 +243,5 @@ public static void AddKeycloakOidcSupport(this IServiceCollection services, ICon services.Configure(configuration.GetSection("KeycloakOidc")); services.AddOptions(); - services.AddSingleton(); } } diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index 5aa062f4..33a4de90 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -1,3 +1,4 @@ +using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints; using AzureOpsCrew.Api.Extensions; using AzureOpsCrew.Api.Settings; @@ -44,7 +45,6 @@ builder.Services.AddDatabase(builder.Configuration); builder.Services.AddProviderFacades(); builder.Services.AddJwtAuthentication(builder.Configuration, builder.Environment); - builder.Services.AddEmailVerification(builder.Configuration); builder.Services.AddKeycloakOidcSupport(builder.Configuration); // Configure AG-UI @@ -91,6 +91,7 @@ app.UseHttpsRedirection(); app.UseAuthentication(); + app.UseMiddleware(); app.UseAuthorization(); // Map endpoints diff --git a/backend/src/Api/Settings/JwtSettings.cs b/backend/src/Api/Settings/JwtSettings.cs deleted file mode 100644 index c848ca82..00000000 --- a/backend/src/Api/Settings/JwtSettings.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace AzureOpsCrew.Api.Settings; - -public sealed class JwtSettings -{ - public string Issuer { get; set; } = string.Empty; - public string Audience { get; set; } = string.Empty; - public string SigningKey { get; set; } = string.Empty; - public int AccessTokenMinutes { get; set; } = 480; -} diff --git a/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index 24ab88b8..008f79aa 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -17,24 +17,6 @@ "SqlServer": { "ConnectionString": "Server=localhost;Database=AzureOpsCrew;Trusted_Connection=True;TrustServerCertificate=True;" }, - "Jwt": { - "Issuer": "AzureOpsCrew", - "Audience": "AzureOpsCrewFrontend", - "SigningKey": "", - "AccessTokenMinutes": 480 - }, - "EmailVerification": { - "CodeLength": 6, - "CodeTtlMinutes": 10, - "ResendCooldownSeconds": 30, - "MaxVerificationAttempts": 5 - }, - "Brevo": { - "ApiBaseUrl": "https://api.brevo.com", - "ApiKey": "", - "SenderEmail": "azureopscrew@aoc-app.com", - "SenderName": "Azure Ops Crew" - }, "KeycloakOidc": { "Enabled": false, "Authority": "", diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts index 701b625f..a5f7047f 100644 --- a/frontend/app/api/auth/keycloak/callback/route.ts +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -17,14 +17,11 @@ export const dynamic = "force-dynamic" const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" -interface BackendAuthResponse { - accessToken: string - expiresAtUtc: string - user: { - id: number - email: string - displayName: string - } +interface KeycloakTokenResponse { + access_token: string + id_token?: string + token_type?: string + expires_in?: number } function buildLoginRedirect(req: NextRequest, message: string) { @@ -105,21 +102,22 @@ export async function GET(req: NextRequest) { cache: "no-store", }) - const tokenData = await tokenResponse.json().catch(() => ({})) - if (!tokenResponse.ok || typeof tokenData?.id_token !== "string") { + const tokenData = (await tokenResponse.json().catch(() => ({}))) as Partial + if (!tokenResponse.ok || typeof tokenData?.access_token !== "string") { console.warn("Keycloak token exchange failed", { status: tokenResponse.status, - hasIdToken: typeof tokenData?.id_token === "string", + hasAccessToken: typeof tokenData?.access_token === "string", }) const response = NextResponse.redirect(buildLoginRedirect(req, "Keycloak sign-in failed")) clearKeycloakTransientCookies(response) return response } - const backendResponse = await fetch(`${BACKEND_API_URL}/api/auth/keycloak/exchange`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ idToken: tokenData.id_token }), + // Validate/provision the local app user before redirecting to the app UI. + // Backend now accepts Keycloak-issued access tokens directly. + const backendResponse = await fetch(`${BACKEND_API_URL}/api/auth/me`, { + method: "GET", + headers: { Authorization: `Bearer ${tokenData.access_token}` }, cache: "no-store", }) @@ -136,17 +134,25 @@ export async function GET(req: NextRequest) { return response } - const authData = backendData as BackendAuthResponse - if (!authData.accessToken) { - const response = NextResponse.redirect(buildLoginRedirect(req, "Invalid auth response")) - clearKeycloakTransientCookies(response) - return response - } + const accessTokenTtlSeconds = + Number.isFinite(Number(tokenData.expires_in)) && Number(tokenData.expires_in) > 0 + ? Math.floor(Number(tokenData.expires_in)) + : undefined const redirectUrl = new URL(nextPath, getPublicRequestOrigin(req)) const response = NextResponse.redirect(redirectUrl) - response.cookies.set(ACCESS_TOKEN_COOKIE_NAME, authData.accessToken, getAuthCookieOptions()) - response.cookies.set(KEYCLOAK_ID_TOKEN_COOKIE_NAME, tokenData.id_token, getAuthCookieOptions()) + response.cookies.set( + ACCESS_TOKEN_COOKIE_NAME, + tokenData.access_token, + getAuthCookieOptions(accessTokenTtlSeconds) + ) + if (typeof tokenData.id_token === "string" && tokenData.id_token.length > 0) { + response.cookies.set( + KEYCLOAK_ID_TOKEN_COOKIE_NAME, + tokenData.id_token, + getAuthCookieOptions(accessTokenTtlSeconds) + ) + } clearKeycloakTransientCookies(response) return response } catch (error) { diff --git a/frontend/lib/server/auth.ts b/frontend/lib/server/auth.ts index 4b0fa398..f3a9f37f 100644 --- a/frontend/lib/server/auth.ts +++ b/frontend/lib/server/auth.ts @@ -27,12 +27,12 @@ export function buildBackendHeaders( return headers } -export function getAuthCookieOptions() { +export function getAuthCookieOptions(maxAgeSeconds: number = ACCESS_TOKEN_TTL_SECONDS) { return { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict" as const, path: "/", - maxAge: ACCESS_TOKEN_TTL_SECONDS, + maxAge: maxAgeSeconds, } } From c60c82b8e720076e7066eab2768ebb84633092c2 Mon Sep 17 00:00:00 2001 From: Ilia Date: Tue, 24 Feb 2026 01:56:38 -0600 Subject: [PATCH 31/37] Remove unused legacy email verification services --- .../Api/Email/BrevoRegistrationEmailSender.cs | 78 ------------------- .../src/Api/Email/IRegistrationEmailSender.cs | 10 --- .../Extensions/ServiceCollectionExtensions.cs | 51 ------------ backend/src/Api/Settings/BrevoSettings.cs | 9 --- .../Api/Settings/EmailVerificationSettings.cs | 9 --- 5 files changed, 157 deletions(-) delete mode 100644 backend/src/Api/Email/BrevoRegistrationEmailSender.cs delete mode 100644 backend/src/Api/Email/IRegistrationEmailSender.cs delete mode 100644 backend/src/Api/Settings/BrevoSettings.cs delete mode 100644 backend/src/Api/Settings/EmailVerificationSettings.cs diff --git a/backend/src/Api/Email/BrevoRegistrationEmailSender.cs b/backend/src/Api/Email/BrevoRegistrationEmailSender.cs deleted file mode 100644 index 9f8b6f70..00000000 --- a/backend/src/Api/Email/BrevoRegistrationEmailSender.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Net.Http.Json; -using AzureOpsCrew.Api.Settings; -using Microsoft.Extensions.Options; - -namespace AzureOpsCrew.Api.Email; - -public sealed class BrevoRegistrationEmailSender : IRegistrationEmailSender -{ - private readonly HttpClient _httpClient; - private readonly BrevoSettings _settings; - private readonly ILogger _logger; - - public BrevoRegistrationEmailSender( - HttpClient httpClient, - IOptions settings, - ILogger logger) - { - _httpClient = httpClient; - _settings = settings.Value; - _logger = logger; - } - - public async Task SendRegistrationCodeAsync( - string recipientEmail, - string verificationCode, - DateTime expiresAtUtc, - CancellationToken cancellationToken) - { - var subject = "Your Azure Ops Crew security code"; - var expiresAt = expiresAtUtc.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"); - var htmlContent = - $""" - - -

Your Azure Ops Crew verification code is:

-

{verificationCode}

-

This code expires at {expiresAt}.

-

If you did not request this, you can safely ignore this email.

- - - """; - var textContent = - $"Your Azure Ops Crew verification code is: {verificationCode}. This code expires at {expiresAt}."; - - using var request = new HttpRequestMessage(HttpMethod.Post, "/v3/smtp/email") - { - Content = JsonContent.Create(new BrevoSendEmailRequest( - Sender: new BrevoContact(_settings.SenderEmail, _settings.SenderName), - To: [new BrevoContact(recipientEmail, null)], - Subject: subject, - HtmlContent: htmlContent, - TextContent: textContent)) - }; - - request.Headers.Add("api-key", _settings.ApiKey); - - using var response = await _httpClient.SendAsync(request, cancellationToken); - if (response.IsSuccessStatusCode) - return; - - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError( - "Brevo email send failed. StatusCode={StatusCode}. Response={Response}", - (int)response.StatusCode, - responseBody); - - throw new InvalidOperationException("Unable to send verification email."); - } - - private sealed record BrevoContact(string Email, string? Name); - - private sealed record BrevoSendEmailRequest( - BrevoContact Sender, - BrevoContact[] To, - string Subject, - string HtmlContent, - string TextContent); -} diff --git a/backend/src/Api/Email/IRegistrationEmailSender.cs b/backend/src/Api/Email/IRegistrationEmailSender.cs deleted file mode 100644 index de0bde98..00000000 --- a/backend/src/Api/Email/IRegistrationEmailSender.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AzureOpsCrew.Api.Email; - -public interface IRegistrationEmailSender -{ - Task SendRegistrationCodeAsync( - string recipientEmail, - string verificationCode, - DateTime expiresAtUtc, - CancellationToken cancellationToken); -} diff --git a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs index ae525938..a46fd153 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using System.IdentityModel.Tokens.Jwt; using AzureOpsCrew.Api.Auth; -using AzureOpsCrew.Api.Email; using AzureOpsCrew.Api.Settings; using AzureOpsCrew.Domain.Providers; using AzureOpsCrew.Domain.Users; @@ -14,7 +13,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; -using Microsoft.Extensions.Options; namespace AzureOpsCrew.Api.Extensions; @@ -173,55 +171,6 @@ public static void AddJwtAuthentication(this IServiceCollection services, IConfi services.AddScoped(); } - public static void AddEmailVerification(this IServiceCollection services, IConfiguration configuration) - { - var brevoSettings = configuration.GetSection("Brevo").Get() ?? new BrevoSettings(); - var emailVerificationSettings = configuration.GetSection("EmailVerification").Get() - ?? new EmailVerificationSettings(); - - if (string.IsNullOrWhiteSpace(brevoSettings.ApiBaseUrl)) - throw new InvalidOperationException("Brevo__ApiBaseUrl is required."); - - if (string.IsNullOrWhiteSpace(brevoSettings.ApiKey)) - throw new InvalidOperationException("Brevo__ApiKey is required."); - - if (brevoSettings.ApiKey.Contains("CHANGEME", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("A real Brevo API key must be configured."); - - if (string.IsNullOrWhiteSpace(brevoSettings.SenderEmail)) - throw new InvalidOperationException("Brevo__SenderEmail is required."); - - if (string.IsNullOrWhiteSpace(brevoSettings.SenderName)) - throw new InvalidOperationException("Brevo__SenderName is required."); - - if (emailVerificationSettings.CodeLength is < 4 or > 8) - throw new InvalidOperationException("EmailVerification__CodeLength must be between 4 and 8."); - - if (emailVerificationSettings.CodeTtlMinutes <= 0) - throw new InvalidOperationException("EmailVerification__CodeTtlMinutes must be greater than zero."); - - if (emailVerificationSettings.ResendCooldownSeconds < 0) - throw new InvalidOperationException("EmailVerification__ResendCooldownSeconds must be zero or greater."); - - if (emailVerificationSettings.MaxVerificationAttempts <= 0) - throw new InvalidOperationException("EmailVerification__MaxVerificationAttempts must be greater than zero."); - - services.Configure(configuration.GetSection("Brevo")); - services.AddOptions(); - - services.Configure(configuration.GetSection("EmailVerification")); - services.AddOptions(); - - services.AddHttpClient((sp, client) => - { - var settings = sp.GetRequiredService>().Value; - client.BaseAddress = new Uri(settings.ApiBaseUrl); - client.Timeout = TimeSpan.FromSeconds(15); - }); - - services.AddScoped, PasswordHasher>(); - } - public static void AddKeycloakOidcSupport(this IServiceCollection services, IConfiguration configuration) { var settings = configuration.GetSection("KeycloakOidc").Get() ?? new KeycloakOidcSettings(); diff --git a/backend/src/Api/Settings/BrevoSettings.cs b/backend/src/Api/Settings/BrevoSettings.cs deleted file mode 100644 index ab2c23af..00000000 --- a/backend/src/Api/Settings/BrevoSettings.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace AzureOpsCrew.Api.Settings; - -public sealed class BrevoSettings -{ - public string ApiBaseUrl { get; set; } = "https://api.brevo.com"; - public string ApiKey { get; set; } = string.Empty; - public string SenderEmail { get; set; } = "azureopscrew@aoc-app.com"; - public string SenderName { get; set; } = "Azure Ops Crew"; -} diff --git a/backend/src/Api/Settings/EmailVerificationSettings.cs b/backend/src/Api/Settings/EmailVerificationSettings.cs deleted file mode 100644 index 6de6d4ba..00000000 --- a/backend/src/Api/Settings/EmailVerificationSettings.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace AzureOpsCrew.Api.Settings; - -public sealed class EmailVerificationSettings -{ - public int CodeLength { get; set; } = 6; - public int CodeTtlMinutes { get; set; } = 10; - public int ResendCooldownSeconds { get; set; } = 30; - public int MaxVerificationAttempts { get; set; } = 5; -} From 24c1ef2fafd6b5734072b93a4efcb61277d3c4df Mon Sep 17 00:00:00 2001 From: Ilia Date: Tue, 24 Feb 2026 17:20:07 -0600 Subject: [PATCH 32/37] Harden Keycloak callback against redirect loops --- .../app/api/auth/keycloak/callback/route.ts | 37 ++++++++++++++++--- frontend/lib/server/keycloak.ts | 8 ++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts index a5f7047f..03429137 100644 --- a/frontend/app/api/auth/keycloak/callback/route.ts +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -24,6 +24,8 @@ interface KeycloakTokenResponse { expires_in?: number } +const MAX_ID_TOKEN_COOKIE_CHARS = 3000 + function buildLoginRedirect(req: NextRequest, message: string) { const loginUrl = new URL("/login", getPublicRequestOrigin(req)) loginUrl.searchParams.set("error", message) @@ -43,6 +45,7 @@ export async function GET(req: NextRequest) { if (!config) { return NextResponse.redirect(buildLoginRedirect(req, "Keycloak is not configured")) } + const existingAccessToken = req.cookies.get(ACCESS_TOKEN_COOKIE_NAME)?.value const error = req.nextUrl.searchParams.get("error") if (error) { @@ -51,7 +54,15 @@ export async function GET(req: NextRequest) { error, errorDescription, path: req.nextUrl.pathname, + hasExistingAccessToken: Boolean(existingAccessToken), }) + + if (existingAccessToken) { + const response = NextResponse.redirect(new URL("/", getPublicRequestOrigin(req))) + clearKeycloakTransientCookies(response) + return response + } + const response = NextResponse.redirect( buildLoginRedirect(req, errorDescription ?? error) ) @@ -72,8 +83,18 @@ export async function GET(req: NextRequest) { hasExpectedState: Boolean(expectedState), stateMatches: Boolean(state && expectedState && state === expectedState), hasCodeVerifier: Boolean(codeVerifier), + hasExistingAccessToken: Boolean(existingAccessToken), userAgent: req.headers.get("user-agent"), }) + + // Duplicate/stale callbacks can arrive after a successful sign-in (especially with + // federated IdP + MFA). If the session cookie already exists, avoid restarting login. + if (existingAccessToken) { + const response = NextResponse.redirect(new URL(nextPath, getPublicRequestOrigin(req))) + clearKeycloakTransientCookies(response) + return response + } + const response = NextResponse.redirect(buildLoginRedirect(req, "Invalid sign-in callback")) clearKeycloakTransientCookies(response) return response @@ -147,11 +168,17 @@ export async function GET(req: NextRequest) { getAuthCookieOptions(accessTokenTtlSeconds) ) if (typeof tokenData.id_token === "string" && tokenData.id_token.length > 0) { - response.cookies.set( - KEYCLOAK_ID_TOKEN_COOKIE_NAME, - tokenData.id_token, - getAuthCookieOptions(accessTokenTtlSeconds) - ) + if (tokenData.id_token.length <= MAX_ID_TOKEN_COOKIE_CHARS) { + response.cookies.set( + KEYCLOAK_ID_TOKEN_COOKIE_NAME, + tokenData.id_token, + getAuthCookieOptions(accessTokenTtlSeconds) + ) + } else { + console.warn("Skipping Keycloak id_token cookie because payload is too large", { + idTokenLength: tokenData.id_token.length, + }) + } } clearKeycloakTransientCookies(response) return response diff --git a/frontend/lib/server/keycloak.ts b/frontend/lib/server/keycloak.ts index 3efe5484..85356f46 100644 --- a/frontend/lib/server/keycloak.ts +++ b/frontend/lib/server/keycloak.ts @@ -133,9 +133,11 @@ export function getTransientAuthCookieOptions() { return { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "lax" as const, + // OIDC broker flows (Keycloak -> Entra -> app callback) can lose Lax cookies in + // some browser/privacy/MFA redirect chains. None is safer for transient auth state. + sameSite: "none" as const, path: "/", - maxAge: 60 * 10, + maxAge: 60 * 30, } } @@ -143,7 +145,7 @@ export function clearTransientAuthCookieOptions() { return { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "lax" as const, + sameSite: "none" as const, path: "/", maxAge: 0, } From 0da3d8dfd1e42491f8b1876f9719175af9909f3b Mon Sep 17 00:00:00 2001 From: Ilia Date: Tue, 24 Feb 2026 17:27:46 -0600 Subject: [PATCH 33/37] Stabilize OIDC transient cookies for Entra callback flow --- frontend/app/api/auth/keycloak/start/route.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 17cba8fe..3ce6706e 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -6,7 +6,6 @@ import { getKeycloakAuthFeatureConfig, getPublicRequestOrigin, getKeycloakWebConfig, - getTransientAuthCookieOptions, KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, KEYCLOAK_NEXT_COOKIE_NAME, @@ -17,6 +16,16 @@ import { export const dynamic = "force-dynamic" +function getOidcStartCookieOptions() { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "none" as const, + path: "/", + maxAge: 60 * 30, + } +} + function htmlDecode(value: string): string { return value .replace(/&/g, "&") @@ -79,7 +88,7 @@ export async function GET(req: NextRequest) { const response = NextResponse.redirect(loginUrl) response.cookies.set(KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, "0", { - ...getTransientAuthCookieOptions(), + ...getOidcStartCookieOptions(), maxAge: 0, }) return response @@ -142,13 +151,14 @@ export async function GET(req: NextRequest) { } const response = NextResponse.redirect(redirectUrl) - response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, state, getTransientAuthCookieOptions()) - response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, verifier, getTransientAuthCookieOptions()) - response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, nextPath, getTransientAuthCookieOptions()) + const startCookieOptions = getOidcStartCookieOptions() + response.cookies.set(KEYCLOAK_STATE_COOKIE_NAME, state, startCookieOptions) + response.cookies.set(KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, verifier, startCookieOptions) + response.cookies.set(KEYCLOAK_NEXT_COOKIE_NAME, nextPath, startCookieOptions) response.cookies.set( KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, String(mode === "signup" ? 0 : currentAttemptCount + 1), - getTransientAuthCookieOptions() + startCookieOptions ) for (const setCookie of upstreamKeycloakCookies) { From 9e8b60d27062302b61bd437f41805b2401d7864e Mon Sep 17 00:00:00 2001 From: Ilia Date: Tue, 24 Feb 2026 18:15:06 -0600 Subject: [PATCH 34/37] fix(auth): resolve infinite login loop and improve Entra SSO flow Root causes fixed: 1. Changed access token cookie SameSite from 'strict' to 'lax' - strict cookies are not sent on cross-site top-level navigations (post-OAuth redirects from Entra/Keycloak), causing the middleware to think the user is unauthenticated and redirect back to /login, creating an infinite loop. 2. Removed prompt=login and max_age=0 from Keycloak auth URL - these forced re-authentication on every request, preventing Keycloak SSO session reuse and amplifying the redirect loop problem. 3. Created middleware.ts to wire up the proxy.ts auth guard - the proxy function existed but was never connected to Next.js middleware. 4. Added user-friendly error messages for Entra access denied errors (e.g. users not in the required security group) instead of showing raw Keycloak error pages. --- .../app/api/auth/keycloak/callback/route.ts | 28 ++++++++++++++++++- frontend/app/api/auth/keycloak/start/route.ts | 10 ++++--- frontend/lib/server/auth.ts | 2 +- frontend/middleware.ts | 4 +++ 4 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 frontend/middleware.ts diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts index 03429137..cd82715a 100644 --- a/frontend/app/api/auth/keycloak/callback/route.ts +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -63,8 +63,34 @@ export async function GET(req: NextRequest) { return response } + // Translate common Entra / Keycloak broker errors into user-friendly messages. + let friendlyMessage = errorDescription ?? error + const lowerDesc = (errorDescription ?? "").toLowerCase() + const lowerError = error.toLowerCase() + + if ( + lowerError === "access_denied" || + lowerDesc.includes("aadsts50105") || + lowerDesc.includes("not assigned a role") || + lowerDesc.includes("does not have access") || + lowerDesc.includes("user assignment required") + ) { + friendlyMessage = + "Your Microsoft account is not authorized to access this application. " + + "Please contact an administrator to be added to the access group." + } else if ( + lowerError === "login_required" || + lowerDesc.includes("interaction_required") + ) { + friendlyMessage = + "Microsoft sign-in was cancelled or timed out. Please try again." + } else if (lowerError === "temporarily_unavailable") { + friendlyMessage = + "The identity provider is temporarily unavailable. Please try again in a few minutes." + } + const response = NextResponse.redirect( - buildLoginRedirect(req, errorDescription ?? error) + buildLoginRedirect(req, friendlyMessage) ) clearKeycloakTransientCookies(response) return response diff --git a/frontend/app/api/auth/keycloak/start/route.ts b/frontend/app/api/auth/keycloak/start/route.ts index 3ce6706e..a5163604 100644 --- a/frontend/app/api/auth/keycloak/start/route.ts +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -118,11 +118,13 @@ export async function GET(req: NextRequest) { authUrl.searchParams.set("code_challenge", challenge) authUrl.searchParams.set("code_challenge_method", "S256") if (mode !== "signup" && !features.localLoginEnabled && features.entraSsoEnabled) { - // In Entra-only mode, force a fresh brokered auth so Keycloak doesn't silently - // reuse an existing SSO session after group membership was changed in Entra. + // In Entra-only mode, auto-redirect to the Entra IdP without showing the + // Keycloak login page. Do NOT set prompt=login or max_age=0 — those force + // a fresh authentication on every request and cause redirect loops when + // combined with SameSite cookie policies after the OAuth redirect chain. + // Keycloak will reuse the existing SSO session if one is valid, which is + // the correct and expected behaviour for a seamless sign-in experience. authUrl.searchParams.set("kc_idp_hint", features.entraIdpHint) - authUrl.searchParams.set("prompt", "login") - authUrl.searchParams.set("max_age", "0") } let redirectUrl = authUrl diff --git a/frontend/lib/server/auth.ts b/frontend/lib/server/auth.ts index f3a9f37f..b5d13ab6 100644 --- a/frontend/lib/server/auth.ts +++ b/frontend/lib/server/auth.ts @@ -31,7 +31,7 @@ export function getAuthCookieOptions(maxAgeSeconds: number = ACCESS_TOKEN_TTL_SE return { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict" as const, + sameSite: "lax" as const, path: "/", maxAge: maxAgeSeconds, } diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..f8e2f5dd --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,4 @@ +import { proxy, config as proxyConfig } from "./proxy" + +export { proxyConfig as config } +export default proxy From c42dcd796c6fdd2d060176ef62cce6634e7f291e Mon Sep 17 00:00:00 2001 From: Ilia Date: Tue, 24 Feb 2026 18:54:42 -0600 Subject: [PATCH 35/37] fix(build): remove deprecated middleware.ts for Next.js 16 proxy convention --- frontend/middleware.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 frontend/middleware.ts diff --git a/frontend/middleware.ts b/frontend/middleware.ts deleted file mode 100644 index f8e2f5dd..00000000 --- a/frontend/middleware.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { proxy, config as proxyConfig } from "./proxy" - -export { proxyConfig as config } -export default proxy From 1ee9c8cf5b8b163416e6b7ea79a5f2cb3a98528d Mon Sep 17 00:00:00 2001 From: Ilia Date: Tue, 24 Feb 2026 22:35:06 -0600 Subject: [PATCH 36/37] fix: address all critical and major CodeRabbit review findings - fix(backend): catch DbUpdateException on both retry attempts so Fail(503) is reachable - fix(backend): sync user.Email on Keycloak email change (was only updating linkedIdentity) - fix(backend): add User.UpdateEmail() method to Domain entity - fix(backend): middleware returns 401 instead of failing open when ClaimsIdentity is missing - fix(backend): remove hardcoded JWT_SIGNING_KEY fallback from docker-compose (fail fast) - fix(backend): delete dead RegisterRequestDto (serves 410-Gone endpoint only) - fix(frontend): getPublicRequestOrigin throws in production if PUBLIC_APP_URL is not set (prevents open redirect) - fix(frontend): transient auth cookies use SameSite=lax in dev to avoid Secure requirement - fix(frontend): tighten proxy auth-bypass to exact /api/auth match - fix(frontend): copilotkit info route uses header allow-list instead of cloning all headers - fix(ci): set KEYCLOAK_LOCAL_LOGIN/SIGNUP_ENABLED=false for dev|prod (align with Terraform) --- .github/workflows/deploy.yml | 4 ++-- backend/docker-compose.yml | 2 +- .../Api/Auth/KeycloakAppUserSyncMiddleware.cs | 7 +++++- .../Api/Auth/KeycloakAppUserSyncService.cs | 18 ++++++++++++--- .../Endpoints/Dtos/Auth/RegisterRequestDto.cs | 19 ---------------- backend/src/Domain/Users/User.cs | 7 ++++++ frontend/app/api/copilotkit/info/route.ts | 14 ++++++++---- frontend/lib/server/keycloak.ts | 22 ++++++++++++++----- frontend/proxy.ts | 2 +- 9 files changed, 58 insertions(+), 37 deletions(-) delete mode 100644 backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f93845a..bccc50d3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -87,8 +87,8 @@ jobs: KEYCLOAK_ENTRA_SSO_ENABLED="true" ;; dev|prod) - KEYCLOAK_LOCAL_LOGIN_ENABLED="true" - KEYCLOAK_LOCAL_SIGNUP_ENABLED="true" + KEYCLOAK_LOCAL_LOGIN_ENABLED="false" + KEYCLOAK_LOCAL_SIGNUP_ENABLED="false" KEYCLOAK_ENTRA_SSO_ENABLED="true" ;; *) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index b3aa0ada..cb7bc250 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -13,7 +13,7 @@ services: - SqlServer__ConnectionString=Server=sqlserver;Database=AzureOpsCrew;User Id=sa;Password=YourStrong@Password;TrustServerCertificate=True; - Jwt__Issuer=${JWT_ISSUER:-AzureOpsCrew} - Jwt__Audience=${JWT_AUDIENCE:-AzureOpsCrewFrontend} - - Jwt__SigningKey=${JWT_SIGNING_KEY:-ChangeThisDevelopmentOnlySigningKeyWithAtLeast32Chars!} + - Jwt__SigningKey=${JWT_SIGNING_KEY:?JWT_SIGNING_KEY must be set} - EmailVerification__CodeLength=${EMAIL_VERIFICATION_CODE_LENGTH:-6} - EmailVerification__CodeTtlMinutes=${EMAIL_VERIFICATION_CODE_TTL_MINUTES:-10} - EmailVerification__ResendCooldownSeconds=${EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS:-30} diff --git a/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs b/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs index f112a89d..1fd618fa 100644 --- a/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs +++ b/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs @@ -48,7 +48,12 @@ await httpContext.Response.WriteAsJsonAsync( } else { - _logger.LogWarning("Authenticated principal is not a ClaimsIdentity. Local AppUser claim was not attached."); + _logger.LogWarning("Authenticated principal is not a ClaimsIdentity. Rejecting request."); + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + await httpContext.Response.WriteAsJsonAsync( + new { error = "Unauthorized" }, + cancellationToken: httpContext.RequestAborted); + return; } await _next(httpContext); diff --git a/backend/src/Api/Auth/KeycloakAppUserSyncService.cs b/backend/src/Api/Auth/KeycloakAppUserSyncService.cs index 018d9de5..a36adb16 100644 --- a/backend/src/Api/Auth/KeycloakAppUserSyncService.cs +++ b/backend/src/Api/Auth/KeycloakAppUserSyncService.cs @@ -118,6 +118,11 @@ public async Task EnsureUserAsync( else { linkedIdentity.UpdateEmail(email); + // Also sync the canonical user email when it changes in Keycloak. + if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) + { + user.UpdateEmail(email, normalizedEmail); + } } if (!string.IsNullOrWhiteSpace(displayName) && @@ -145,10 +150,17 @@ await UserWorkspaceDefaults.EnsureAsync( return KeycloakAppUserSyncResult.Success(user.Id, user.Email, user.DisplayName); } - catch (DbUpdateException) when (attempt == 0) + catch (DbUpdateException) { - _context.ChangeTracker.Clear(); - createdUser = false; + if (attempt == 0) + { + _context.ChangeTracker.Clear(); + createdUser = false; + } + else + { + return KeycloakAppUserSyncResult.Fail(503, "Unable to synchronize user profile."); + } } } diff --git a/backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs b/backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs deleted file mode 100644 index 995ea343..00000000 --- a/backend/src/Api/Endpoints/Dtos/Auth/RegisterRequestDto.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace AzureOpsCrew.Api.Endpoints.Dtos.Auth; - -public sealed class RegisterRequestDto -{ - [Required] - [EmailAddress] - [StringLength(320, MinimumLength = 3)] - public string Email { get; set; } = string.Empty; - - [Required] - [MinLength(8)] - [MaxLength(128)] - public string Password { get; set; } = string.Empty; - - [StringLength(120, MinimumLength = 2)] - public string? DisplayName { get; set; } -} diff --git a/backend/src/Domain/Users/User.cs b/backend/src/Domain/Users/User.cs index ee0a6b3f..e6de701a 100644 --- a/backend/src/Domain/Users/User.cs +++ b/backend/src/Domain/Users/User.cs @@ -26,6 +26,13 @@ public User(string email, string normalizedEmail, string passwordHash, string di public DateTime? DateModified { get; private set; } public DateTime? LastLoginAt { get; private set; } + public void UpdateEmail(string email, string normalizedEmail) + { + Email = email; + NormalizedEmail = normalizedEmail; + DateModified = DateTime.UtcNow; + } + public void UpdateDisplayName(string displayName) { DisplayName = displayName; diff --git a/frontend/app/api/copilotkit/info/route.ts b/frontend/app/api/copilotkit/info/route.ts index d5b87340..08917e6e 100644 --- a/frontend/app/api/copilotkit/info/route.ts +++ b/frontend/app/api/copilotkit/info/route.ts @@ -1,13 +1,19 @@ import { NextRequest } from "next/server" export async function GET(req: NextRequest) { - const headers = new Headers(req.headers) - headers.set("content-type", "application/json") - headers.delete("content-length") + // Use an explicit allow-list to avoid forwarding user-controlled headers + // (e.g. Host, x-forwarded-host) to the internal CopilotKit route. + const forwardHeaders = new Headers() + forwardHeaders.set("content-type", "application/json") + const allowList = ["authorization", "x-request-id", "cookie"] + for (const header of allowList) { + const value = req.headers.get(header) + if (value) forwardHeaders.set(header, value) + } const response = await fetch(new URL("/api/copilotkit", req.url), { method: "POST", - headers, + headers: forwardHeaders, body: JSON.stringify({ method: "info" }), cache: "no-store", }) diff --git a/frontend/lib/server/keycloak.ts b/frontend/lib/server/keycloak.ts index 85356f46..8fadeec7 100644 --- a/frontend/lib/server/keycloak.ts +++ b/frontend/lib/server/keycloak.ts @@ -92,6 +92,14 @@ export function getPublicRequestOrigin(req: NextRequest): string { } } + // In production PUBLIC_APP_URL must be set — falling back to untrusted + // request headers enables open-redirect attacks. + if (process.env.NODE_ENV === "production") { + throw new Error( + "PUBLIC_APP_URL environment variable must be configured in production." + ) + } + const forwardedProto = firstHeaderValue(req.headers.get("x-forwarded-proto")) const forwardedHost = firstHeaderValue(req.headers.get("x-forwarded-host")) ?? @@ -130,22 +138,24 @@ export function createRandomState() { } export function getTransientAuthCookieOptions() { + const isProduction = process.env.NODE_ENV === "production" return { httpOnly: true, - secure: process.env.NODE_ENV === "production", - // OIDC broker flows (Keycloak -> Entra -> app callback) can lose Lax cookies in - // some browser/privacy/MFA redirect chains. None is safer for transient auth state. - sameSite: "none" as const, + secure: isProduction, + // SameSite=None requires Secure=true (enforced by all modern browsers since 2020). + // In development (Secure=false) use Lax, which works fine for localhost OIDC flows. + sameSite: (isProduction ? "none" : "lax") as "none" | "lax", path: "/", maxAge: 60 * 30, } } export function clearTransientAuthCookieOptions() { + const isProduction = process.env.NODE_ENV === "production" return { httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "none" as const, + secure: isProduction, + sameSite: (isProduction ? "none" : "lax") as "none" | "lax", path: "/", maxAge: 0, } diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 1a8a7082..67c47d6b 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -14,7 +14,7 @@ export function proxy(req: NextRequest) { return NextResponse.next() } - if (pathname.startsWith("/api/auth")) { + if (pathname === "/api/auth" || pathname.startsWith("/api/auth/")) { return NextResponse.next() } From 5d98028434f5274ca87d788f8883e7da559336f4 Mon Sep 17 00:00:00 2001 From: Ilia Date: Wed, 25 Feb 2026 00:21:37 -0600 Subject: [PATCH 37/37] fix: address hidden CodeRabbit review findings - docker-compose: externalize SqlServer SA password to env var - ServiceCollectionExtensions: gate HTTPS check on non-dev envs only - Program.cs: pass IHostEnvironment to AddKeycloakOidcSupport - UserExternalIdentityEntityTypeConfiguration: add HasOne FK cascade delete - PendingRegistration.Refresh: also update NormalizedEmail - callback/route.ts: add AbortSignal.timeout(10_000) to both fetch calls --- backend/docker-compose.yml | 2 +- backend/src/Api/Extensions/ServiceCollectionExtensions.cs | 7 ++++--- backend/src/Api/Program.cs | 2 +- backend/src/Domain/Users/PendingRegistration.cs | 2 ++ .../Sqlite/UserExternalIdentityEntityTypeConfiguration.cs | 5 +++++ frontend/app/api/auth/keycloak/callback/route.ts | 2 ++ 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index cb7bc250..e010714d 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -10,7 +10,7 @@ services: - ASPNETCORE_URLS=http://*:80 - DatabaseProvider=Sqlite - Sqlite__DataSource=Data Source=/app/data/azureopscrew.db - - SqlServer__ConnectionString=Server=sqlserver;Database=AzureOpsCrew;User Id=sa;Password=YourStrong@Password;TrustServerCertificate=True; + - SqlServer__ConnectionString=Server=sqlserver;Database=AzureOpsCrew;User Id=sa;Password=${SQL_SERVER_SA_PASSWORD:-YourStrong@Password};TrustServerCertificate=True; - Jwt__Issuer=${JWT_ISSUER:-AzureOpsCrew} - Jwt__Audience=${JWT_AUDIENCE:-AzureOpsCrewFrontend} - Jwt__SigningKey=${JWT_SIGNING_KEY:?JWT_SIGNING_KEY must be set} diff --git a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs index a46fd153..f56715b6 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -171,7 +171,7 @@ public static void AddJwtAuthentication(this IServiceCollection services, IConfi services.AddScoped(); } - public static void AddKeycloakOidcSupport(this IServiceCollection services, IConfiguration configuration) + public static void AddKeycloakOidcSupport(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) { var settings = configuration.GetSection("KeycloakOidc").Get() ?? new KeycloakOidcSettings(); @@ -183,8 +183,9 @@ public static void AddKeycloakOidcSupport(this IServiceCollection services, ICon if (!Uri.TryCreate(settings.Authority, UriKind.Absolute, out var authorityUri)) throw new InvalidOperationException("KeycloakOidc__Authority must be an absolute URL."); - if (!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("KeycloakOidc__Authority must use HTTPS."); + if (!environment.IsDevelopment() && + !string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("KeycloakOidc__Authority must use HTTPS in non-development environments."); if (string.IsNullOrWhiteSpace(settings.ClientId)) throw new InvalidOperationException("KeycloakOidc__ClientId is required when KeycloakOidc__Enabled=true."); diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index 33a4de90..166c74b3 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -45,7 +45,7 @@ builder.Services.AddDatabase(builder.Configuration); builder.Services.AddProviderFacades(); builder.Services.AddJwtAuthentication(builder.Configuration, builder.Environment); - builder.Services.AddKeycloakOidcSupport(builder.Configuration); + builder.Services.AddKeycloakOidcSupport(builder.Configuration, builder.Environment); // Configure AG-UI builder.Services.AddHttpClient(); diff --git a/backend/src/Domain/Users/PendingRegistration.cs b/backend/src/Domain/Users/PendingRegistration.cs index b319e531..74713329 100644 --- a/backend/src/Domain/Users/PendingRegistration.cs +++ b/backend/src/Domain/Users/PendingRegistration.cs @@ -28,6 +28,7 @@ public PendingRegistration(string email, string normalizedEmail) public void Refresh( string email, + string normalizedEmail, string displayName, string passwordHash, string verificationCodeHash, @@ -35,6 +36,7 @@ public void Refresh( DateTime codeSentAtUtc) { Email = email; + NormalizedEmail = normalizedEmail; DisplayName = displayName; PasswordHash = passwordHash; VerificationCodeHash = verificationCodeHash; diff --git a/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs index 6ce149ea..7fe720b6 100644 --- a/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs +++ b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs @@ -37,5 +37,10 @@ public void Configure(EntityTypeBuilder builder) .IsUnique(); builder.HasIndex(x => x.UserId); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/frontend/app/api/auth/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts index cd82715a..1828fca8 100644 --- a/frontend/app/api/auth/keycloak/callback/route.ts +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -147,6 +147,7 @@ export async function GET(req: NextRequest) { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: tokenRequestBody.toString(), cache: "no-store", + signal: AbortSignal.timeout(10_000), }) const tokenData = (await tokenResponse.json().catch(() => ({}))) as Partial @@ -166,6 +167,7 @@ export async function GET(req: NextRequest) { method: "GET", headers: { Authorization: `Bearer ${tokenData.access_token}` }, cache: "no-store", + signal: AbortSignal.timeout(10_000), }) const backendData = await backendResponse.json().catch(() => ({}))