diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b71d631e..bccc50d3 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_BASE_URL: https://auth.aoc-app.com + KEYCLOAK_CLIENT_ID: azureopscrew-frontend jobs: build: @@ -27,6 +29,12 @@ jobs: outputs: 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 }} + 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: @@ -44,10 +52,64 @@ jobs: RESOURCE_GROUP="AzureOpsCrew-${ENV_NAME}" ;; esac + + case "$ENV_NAME" in + prod) + FRONTEND_PUBLIC_URL="https://aoc-app.com" + ;; + *) + FRONTEND_PUBLIC_URL="https://${ENV_NAME}.aoc-app.com" + ;; + 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}" + + case "$ENV_NAME" in + test1) + KEYCLOAK_LOCAL_LOGIN_ENABLED="false" + KEYCLOAK_LOCAL_SIGNUP_ENABLED="false" + KEYCLOAK_ENTRA_SSO_ENABLED="true" + ;; + dev|prod) + KEYCLOAK_LOCAL_LOGIN_ENABLED="false" + KEYCLOAK_LOCAL_SIGNUP_ENABLED="false" + 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}" + echo "🎯 Keycloak Realm: ${KEYCLOAK_REALM}" echo "🚀 Deploy enabled: ${{ github.event.inputs.deploy }}" - name: Azure Login @@ -86,6 +148,7 @@ jobs: echo "## 📦 Build Complete" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Environment:** ${{ steps.env.outputs.name }}" >> $GITHUB_STEP_SUMMARY + echo "**Frontend Public URL:** ${{ steps.env.outputs.frontend_public_url }}" >> $GITHUB_STEP_SUMMARY echo "**Backend Image:** \`${{ env.ACR_SERVER }}/backend:${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY echo "**Frontend Image:** \`${{ env.ACR_SERVER }}/frontend:${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY @@ -107,7 +170,12 @@ 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 \ + KeycloakOidc__Enabled=true \ + KeycloakOidc__Authority=${{ needs.build.outputs.keycloak_authority }} \ + KeycloakOidc__ClientId=${{ env.KEYCLOAK_CLIENT_ID }} \ + KeycloakOidc__RequireVerifiedEmail=true echo "✅ Backend deployed" - name: Deploy Frontend @@ -116,7 +184,16 @@ 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=${{ 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_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/backend/.env.example b/backend/.env.example index 4571f4f9..e8f8ce6d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,7 @@ -AZURE_OPENAI_API_KEY= \ No newline at end of file +AZURE_OPENAI_API_ENDPOINT= +AZURE_OPENAI_API_KEY= +KEYCLOAK_OIDC_ENABLED=false +KEYCLOAK_OIDC_AUTHORITY= +KEYCLOAK_OIDC_CLIENT_ID= +KEYCLOAK_OIDC_REQUIRE_VERIFIED_EMAIL=true +SEEDING_ENABLED=false diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 4e39f5de..e010714d 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -9,10 +9,28 @@ services: - ASPNETCORE_HTTP_PORTS=80 - ASPNETCORE_URLS=http://*:80 - DatabaseProvider=Sqlite - - Sqlite__DataSource=Data Source=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} + - Sqlite__DataSource=Data Source=/app/data/azureopscrew.db + - 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} + - 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} + - 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:-} + 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..bd778d5b --- /dev/null +++ b/backend/src/Api/Auth/AuthenticatedUserExtensions.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using System.IdentityModel.Tokens.Jwt; + +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(AppUserIdClaimType)?.Value + ?? 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/KeycloakAppUserSyncMiddleware.cs b/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs new file mode 100644 index 00000000..1fd618fa --- /dev/null +++ b/backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs @@ -0,0 +1,61 @@ +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. 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 new file mode 100644 index 00000000..a36adb16 --- /dev/null +++ b/backend/src/Api/Auth/KeycloakAppUserSyncService.cs @@ -0,0 +1,248 @@ +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); + // 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) && + !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) + { + if (attempt == 0) + { + _context.ChangeTracker.Clear(); + createdUser = false; + } + else + { + return KeycloakAppUserSyncResult.Fail(503, "Unable to synchronize user profile."); + } + } + } + + 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/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..3d3fc176 100644 --- a/backend/src/Api/Endpoints/AgentEndpoints.cs +++ b/backend/src/Api/Endpoints/AgentEndpoints.cs @@ -1,8 +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 { @@ -11,15 +14,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 +53,68 @@ 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, + 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 == 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 +125,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..a5b07bc0 --- /dev/null +++ b/backend/src/Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,62 @@ +using AzureOpsCrew.Api.Auth; +using AzureOpsCrew.Api.Endpoints.Dtos.Auth; +using AzureOpsCrew.Infrastructure.Db; +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", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); + + group.MapPost("/register/resend", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); + + group.MapPost("/register/verify", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .AllowAnonymous(); + + group.MapPost("/login", () => LegacyEmailPasswordAuthDisabled()) + .Produces(StatusCodes.Status410Gone) + .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, + 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(); + + return Results.Ok(new AuthUserDto(user.Id, user.Email, user.DisplayName)); + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .RequireAuthorization(); + } + + private static IResult LegacyEmailPasswordAuthDisabled() => + Results.Json( + new { error = "Email/password authentication is disabled. Use Keycloak sign-in." }, + statusCode: StatusCodes.Status410Gone); +} diff --git a/backend/src/Api/Endpoints/ChannelEndpoints.cs b/backend/src/Api/Endpoints/ChannelEndpoints.cs index b5b98c59..34ffa897 100644 --- a/backend/src/Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/Api/Endpoints/ChannelEndpoints.cs @@ -1,8 +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; @@ -11,25 +14,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 +44,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 +52,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 +83,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 +106,38 @@ public static void MapChannelEndpoints(this IEndpointRouteBuilder routeBuilder) .Produces(StatusCodes.Status400BadRequest); group.MapGet("", async ( - int clientId, + 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 == 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 +146,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/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/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/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/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..751de343 --- /dev/null +++ b/backend/src/Api/Endpoints/Dtos/Users/UserPresenceDto.cs @@ -0,0 +1,8 @@ +namespace AzureOpsCrew.Api.Endpoints.Dtos.Users; + +public sealed record UserPresenceDto( + int Id, + string DisplayName, + bool IsOnline, + bool IsCurrentUser, + DateTime? LastSeenAtUtc); diff --git a/backend/src/Api/Endpoints/ProviderEndpoints.cs b/backend/src/Api/Endpoints/ProviderEndpoints.cs index e0ac0fdd..1c7c2636 100644 --- a/backend/src/Api/Endpoints/ProviderEndpoints.cs +++ b/backend/src/Api/Endpoints/ProviderEndpoints.cs @@ -1,9 +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; @@ -12,18 +15,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 +58,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 +69,23 @@ 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, + 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 == clientId) + .Where(p => p.ClientId == userId) .OrderBy(p => p.DateCreated) .ToListAsync(cancellationToken); @@ -82,11 +96,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 +114,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 +156,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 +170,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 +193,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 +229,7 @@ public static void MapProviderEndpoints(this IEndpointRouteBuilder routeBuilder) { config = new Provider( Guid.Empty, - 0, + userId, body.Name ?? "Test", body.ProviderType, body.ApiKey, @@ -225,7 +248,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 +258,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 +281,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 +290,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..a03106df --- /dev/null +++ b/backend/src/Api/Endpoints/UsersEndpoints.cs @@ -0,0 +1,62 @@ +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); + private const string ExternalIdentityProviderKeycloak = "keycloak"; + + 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 keycloakLinkedUserIds = context.UserExternalIdentities + .Where(x => x.Provider == ExternalIdentityProviderKeycloak) + .Select(x => x.UserId); + + var users = await context.Users + .AsNoTracking() + .Where(u => u.IsActive && keycloakLinkedUserIds.Contains(u.Id)) + .OrderBy(u => u.DisplayName) + .Select(u => new UserPresenceDto( + u.Id, + u.DisplayName, + 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..f56715b6 100644 --- a/backend/src/Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,8 @@ +using System.IdentityModel.Tokens.Jwt; +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,91 @@ public static void AddProviderFacades(this IServiceCollection services) client.Timeout = TimeSpan.FromSeconds(30); }); } + + public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) + { + var keycloak = configuration.GetSection("KeycloakOidc").Get() ?? new KeycloakOidcSettings(); + + if (!keycloak.Enabled) + throw new InvalidOperationException("KeycloakOidc__Enabled=true is required. Backend now accepts only Keycloak-issued access tokens."); + + if (string.IsNullOrWhiteSpace(keycloak.Authority)) + throw new InvalidOperationException("KeycloakOidc__Authority is required when KeycloakOidc__Enabled=true."); + + if (string.IsNullOrWhiteSpace(keycloak.ClientId)) + throw new InvalidOperationException("KeycloakOidc__ClientId is required when KeycloakOidc__Enabled=true."); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.RequireHttpsMetadata = !environment.IsDevelopment(); + options.MapInboundClaims = false; + options.Authority = keycloak.Authority.TrimEnd('/'); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = keycloak.Authority.TrimEnd('/'), + ValidateAudience = false, + ValidateLifetime = true, + 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.AddScoped, PasswordHasher>(); + services.AddScoped(); + } + + public static void AddKeycloakOidcSupport(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) + { + 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 (!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."); + } + + services.Configure(configuration.GetSection("KeycloakOidc")); + services.AddOptions(); + } } diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index d37466a1..166c74b3 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -1,9 +1,9 @@ +using AzureOpsCrew.Api.Auth; using AzureOpsCrew.Api.Endpoints; using AzureOpsCrew.Api.Extensions; using AzureOpsCrew.Api.Settings; using AzureOpsCrew.Api.Setup.Seeds; using Microsoft.Extensions.Options; -using Microsoft.OpenApi; using Newtonsoft.Json; using Serilog; @@ -39,21 +39,13 @@ // 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); + builder.Services.AddKeycloakOidcSupport(builder.Configuration, builder.Environment); // Configure AG-UI builder.Services.AddHttpClient(); @@ -98,8 +90,13 @@ } app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseMiddleware(); + app.UseAuthorization(); // Map endpoints + app.MapAuthEndpoints(); + app.MapUsersEndpoints(); app.MapTestEndpoints(); app.MapAgentEndpoints(); app.MapChannelEndpoints(); 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/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/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index 7c143382..008f79aa 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -17,6 +17,17 @@ "SqlServer": { "ConnectionString": "Server=localhost;Database=AzureOpsCrew;Trusted_Connection=True;TrustServerCertificate=True;" }, - "EnableSeeding": "false", - "SeedingProviderKey": "YOUR_API_KEY" + "KeycloakOidc": { + "Enabled": false, + "Authority": "", + "ClientId": "", + "RequireVerifiedEmail": true + }, + "Seeding": { + "IsEnabled": false, + "AzureFoundrySeed": { + "ApiEndpoint": "", + "Key": "" + } + } } diff --git a/backend/src/Domain/Users/PendingRegistration.cs b/backend/src/Domain/Users/PendingRegistration.cs new file mode 100644 index 00000000..74713329 --- /dev/null +++ b/backend/src/Domain/Users/PendingRegistration.cs @@ -0,0 +1,54 @@ +#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 normalizedEmail, + string displayName, + string passwordHash, + string verificationCodeHash, + DateTime codeExpiresAtUtc, + DateTime codeSentAtUtc) + { + Email = email; + NormalizedEmail = normalizedEmail; + 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/Domain/Users/User.cs b/backend/src/Domain/Users/User.cs new file mode 100644 index 00000000..e6de701a --- /dev/null +++ b/backend/src/Domain/Users/User.cs @@ -0,0 +1,58 @@ +#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 UpdateEmail(string email, string normalizedEmail) + { + Email = email; + NormalizedEmail = normalizedEmail; + DateModified = DateTime.UtcNow; + } + + 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/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 3c88c490..f9ba9414 100644 --- a/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs +++ b/backend/src/Infrastructure.Db/AzureOpsCrewContext.cs @@ -1,9 +1,13 @@ 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 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; @@ -18,11 +22,17 @@ public AzureOpsCrewContext(DbContextOptions options) public DbSet Agents => Set(); public DbSet Channels => Set(); public DbSet Providers => Set(); + public DbSet Users => Set(); + public DbSet PendingRegistrations => Set(); + public DbSet UserExternalIdentities => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new AgentConfig()); 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/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/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/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.cs new file mode 100644 index 00000000..7fe720b6 --- /dev/null +++ b/backend/src/Infrastructure.Db/EntityTypes/Sqlite/UserExternalIdentityEntityTypeConfiguration.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 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); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} 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/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/backend/src/Infrastructure.Db/Migrations/M009_AddUserExternalIdentityTable.cs b/backend/src/Infrastructure.Db/Migrations/M009_AddUserExternalIdentityTable.cs new file mode 100644 index 00000000..65a3beca --- /dev/null +++ b/backend/src/Infrastructure.Db/Migrations/M009_AddUserExternalIdentityTable.cs @@ -0,0 +1,43 @@ +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(); + + // FluentMigrator SQLite generator does not support standalone foreign key expressions. + // Skip FK creation for SQLite to keep local development migrations working. + IfDatabase("SqlServer").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"); + IfDatabase("SqlServer").Delete.ForeignKey("FK_AppUserExternalIdentity_AppUser_UserId").OnTable("AppUserExternalIdentity"); + Delete.Table("AppUserExternalIdentity"); + } +} diff --git a/frontend/.env.example b/frontend/.env.example index c2d4ce52..ac1b16a6 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,17 @@ # 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= + +# Keycloak OIDC configuration (used by Next.js auth callback routes) +# 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= +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/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/keycloak/callback/route.ts b/frontend/app/api/auth/keycloak/callback/route.ts new file mode 100644 index 00000000..1828fca8 --- /dev/null +++ b/frontend/app/api/auth/keycloak/callback/route.ts @@ -0,0 +1,219 @@ +import { NextRequest, NextResponse } from "next/server" +import { ACCESS_TOKEN_COOKIE_NAME, getAuthCookieOptions } from "@/lib/server/auth" +import { + buildKeycloakCallbackUrl, + clearTransientAuthCookieOptions, + getPublicRequestOrigin, + 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, +} from "@/lib/server/keycloak" + +export const dynamic = "force-dynamic" + +const BACKEND_API_URL = process.env.BACKEND_API_URL ?? "http://localhost:5000" + +interface KeycloakTokenResponse { + access_token: string + id_token?: string + token_type?: string + 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) + return loginUrl +} + +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")) + } + const existingAccessToken = req.cookies.get(ACCESS_TOKEN_COOKIE_NAME)?.value + + 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, + hasExistingAccessToken: Boolean(existingAccessToken), + }) + + if (existingAccessToken) { + const response = NextResponse.redirect(new URL("/", getPublicRequestOrigin(req))) + clearKeycloakTransientCookies(response) + 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, friendlyMessage) + ) + clearKeycloakTransientCookies(response) + 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) { + console.warn("Invalid Keycloak callback state", { + hasCode: Boolean(code), + hasState: Boolean(state), + 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 + } + + 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", + signal: AbortSignal.timeout(10_000), + }) + + 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, + hasAccessToken: typeof tokenData?.access_token === "string", + }) + const response = NextResponse.redirect(buildLoginRedirect(req, "Keycloak sign-in failed")) + clearKeycloakTransientCookies(response) + return response + } + + // 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", + signal: AbortSignal.timeout(10_000), + }) + + const backendData = await backendResponse.json().catch(() => ({})) + 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)) + 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, + tokenData.access_token, + getAuthCookieOptions(accessTokenTtlSeconds) + ) + if (typeof tokenData.id_token === "string" && tokenData.id_token.length > 0) { + 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 + } catch (error) { + console.error("Keycloak callback error:", error) + const response = NextResponse.redirect(buildLoginRedirect(req, "Unable to complete sign-in")) + clearKeycloakTransientCookies(response) + 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..a5163604 --- /dev/null +++ b/frontend/app/api/auth/keycloak/start/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from "next/server" +import { + buildKeycloakCallbackUrl, + createPkcePair, + createRandomState, + getKeycloakAuthFeatureConfig, + getPublicRequestOrigin, + getKeycloakWebConfig, + KEYCLOAK_CODE_VERIFIER_COOKIE_NAME, + KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME, + KEYCLOAK_NEXT_COOKIE_NAME, + KEYCLOAK_STATE_COOKIE_NAME, + parseLoginAttemptCount, + toSafeNextPath, +} from "@/lib/server/keycloak" + +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, "&") + .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, 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` +} + +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")) + 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", { + ...getOidcStartCookieOptions(), + maxAge: 0, + }) + return response + } + + 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() + 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" && !features.localLoginEnabled && features.entraSsoEnabled) { + // 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) + } + + let redirectUrl = authUrl + let upstreamKeycloakCookies: string[] = [] + + if (mode === "signup") { + 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((cookie) => rewriteCookieDomainForAoc(cookie, publicOrigin)) + } + } + } catch { + // Fall back to the normal auth URL if the preflight bootstrap fails. + } + } + + const response = NextResponse.redirect(redirectUrl) + 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), + startCookieOptions + ) + + for (const setCookie of upstreamKeycloakCookies) { + response.headers.append("set-cookie", setCookie) + } + + return response +} diff --git a/frontend/app/api/auth/login/route.ts b/frontend/app/api/auth/login/route.ts new file mode 100644 index 00000000..eae45599 --- /dev/null +++ b/frontend/app/api/auth/login/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" + +export async function POST() { + return NextResponse.json( + { error: "Password login is disabled. Use Keycloak sign-in." }, + { status: 410 } + ) +} diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts new file mode 100644 index 00000000..28069fa1 --- /dev/null +++ b/frontend/app/api/auth/logout/route.ts @@ -0,0 +1,61 @@ +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_LOGIN_ATTEMPT_COOKIE_NAME, + KEYCLOAK_NEXT_COOKIE_NAME, + 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() + + 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) + response.cookies.set(KEYCLOAK_LOGIN_ATTEMPT_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)) + postLogoutRedirectUrl.searchParams.set("loggedOut", "1") + 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)) + 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/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/resend/route.ts b/frontend/app/api/auth/register/resend/route.ts new file mode 100644 index 00000000..e6e58861 --- /dev/null +++ b/frontend/app/api/auth/register/resend/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" + +export async function POST() { + return NextResponse.json( + { error: "Email verification sign-up is disabled. Use Keycloak sign-up." }, + { status: 410 } + ) +} diff --git a/frontend/app/api/auth/register/route.ts b/frontend/app/api/auth/register/route.ts new file mode 100644 index 00000000..f361fed8 --- /dev/null +++ b/frontend/app/api/auth/register/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" + +export async function POST() { + return NextResponse.json( + { error: "Email registration is disabled. Use Keycloak sign-up." }, + { status: 410 } + ) +} 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..e6e58861 --- /dev/null +++ b/frontend/app/api/auth/register/verify/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" + +export async function POST() { + return NextResponse.json( + { error: "Email verification sign-up is disabled. Use Keycloak sign-up." }, + { status: 410 } + ) +} 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..0c3c9bef 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,41 @@ 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 = { - id: channelId || crypto.randomUUID(), + if (!channelId) { + return NextResponse.json( + { error: "Channel created but ID not returned by backend" }, + { status: 502 } + ) + } + + const fallbackChannel: Channel = { + id: channelId, 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]/info/route.ts b/frontend/app/api/copilotkit/[agentId]/info/route.ts new file mode 100644 index 00000000..911702f8 --- /dev/null +++ b/frontend/app/api/copilotkit/[agentId]/info/route.ts @@ -0,0 +1,24 @@ +import { NextRequest } from "next/server" + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ agentId: string }> } +) { + const { agentId } = await params + const headers = new Headers(req.headers) + headers.set("content-type", "application/json") + headers.delete("content-length") + + const response = await fetch(new URL(`/api/copilotkit/${agentId}`, req.url), { + method: "POST", + headers, + body: JSON.stringify({ method: "info" }), + cache: "no-store", + }) + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) +} diff --git a/frontend/app/api/copilotkit/[agentId]/route.ts b/frontend/app/api/copilotkit/[agentId]/route.ts index 36b49240..4e1b5d9c 100644 --- a/frontend/app/api/copilotkit/[agentId]/route.ts +++ b/frontend/app/api/copilotkit/[agentId]/route.ts @@ -5,18 +5,34 @@ 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" -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 unauthorized() + } + 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, }) @@ -27,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..08917e6e --- /dev/null +++ b/frontend/app/api/copilotkit/info/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from "next/server" + +export async function GET(req: NextRequest) { + // 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: forwardHeaders, + body: JSON.stringify({ method: "info" }), + cache: "no-store", + }) + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) +} diff --git a/frontend/app/api/copilotkit/route.ts b/frontend/app/api/copilotkit/route.ts index 4c02fbb9..01ed798b 100644 --- a/frontend/app/api/copilotkit/route.ts +++ b/frontend/app/api/copilotkit/route.ts @@ -5,19 +5,34 @@ 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 }) +function unauthorized() { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) +} -const runtime = new CopilotRuntime({ - agents: { aguiAgent } as any, -}) +async function handleCopilotRequest(req: NextRequest) { + const token = getAccessToken(req) + if (!token) { + return unauthorized() + } + + 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(), @@ -25,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/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..b6fab04e 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,31 @@ 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 +} + +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 { - 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 +63,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 ? safeParseSelectedModels(p.selectedModels) : [], + 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..a85863c3 --- /dev/null +++ b/frontend/app/api/users/route.ts @@ -0,0 +1,54 @@ +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 + 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 } + ) + } + + 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), + userId: user.id, + name: user.displayName, + 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/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 = { diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 00000000..39cae2c7 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,70 @@ +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 "/" + if (!value.startsWith("/") || value.startsWith("//")) return "/" + return value +} + +type LoginPageProps = { + searchParams: Promise<{ + next?: string | string[] + error?: string | string[] + loggedOut?: string | string[] + }> +} + +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 + 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 && loggedOut !== "1") { + return ( + + ) + } + + return ( +
+
+

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

+

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

+
+ + {error ? "Try again" : "Sign in"} + + {features.localSignupEnabled ? ( + + Sign up + + ) : null} +
+
+
+ ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 35badcf6..d1d7cc48 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,244 +1,54 @@ -"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" - -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 [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]) - - // Load agents from backend on mount - useEffect(() => { - async function loadAgents() { - try { - setIsLoadingAgents(true) - const response = await fetch("/api/agents?clientId=1") - 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?clientId=1") - 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() - }, []) - - 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 + 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?clientId=1") - 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") + return [] } - 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) - }, []) - - return ( -
- + const data = (await response.json()) as BackendUserPresence[] + return data.map((user): HumanMember => ({ + id: toHumanCardId(user.id), + userId: user.id, + name: user.displayName, + 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/app/signup/page.tsx b/frontend/app/signup/page.tsx new file mode 100644 index 00000000..25f01a65 --- /dev/null +++ b/frontend/app/signup/page.tsx @@ -0,0 +1,22 @@ +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") + } + + return ( + + ) +} 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 + +
+
+ ) +} 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,16 @@ 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)) + }, [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/home-page-client.tsx b/frontend/components/home-page-client.tsx new file mode 100644 index 00000000..2092431d --- /dev/null +++ b/frontend/components/home-page-client.tsx @@ -0,0 +1,325 @@ +"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 { + clearCachedHumans, + getCachedHumans, + setCachedHumans, + 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.length > 0 ? initialHumans : getCachedHumans() + ) + 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(() => { + if (humans.length > 0) { + setCachedHumans(humans) + } + }, [humans]) + + useEffect(() => { + let isCancelled = false + + async function ensureAuthenticated() { + 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" + } + } + } + + 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) + setCachedHumans(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) + setActiveChannelId((current) => { + if (current !== channelId) return current + return next[0]?.id ?? "" + }) + return next + }) + } catch (error) { + console.error("Failed to delete channel:", error) + throw error + } + }, []) + + 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(() => { + clearCachedHumans() + window.location.href = "/api/auth/logout" + }, []) + + return ( +
+ + + {viewMode === "channels" && ( + <> + + {activeChannel ? ( + + ) : isLoadingChannels ? ( +
+
Loading channels...
+
+ ) : ( +
+
No channels found
+
+ )} + + )} + {viewMode === "direct-messages" && ( + setPendingDMMessage(null)} + /> + )} + {viewMode === "settings" && ( + + )} +
+ ) +} 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..1581d261 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, @@ -215,10 +217,14 @@ export function MemberList({ !query || agent.name.toLowerCase().includes(query) const filteredWorking = workingAgents.filter(matchesSearch) const filteredAvailable = availableAgents.filter(matchesSearch) - const currentUserName = displayName || "You" - const filteredHumans = HUMANS.filter((h) => { + 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.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 +343,7 @@ export function MemberList({ Humans {filteredHumans.map((human) => - human.id === HUMAN_ID ? ( + human.isCurrentUser ? ( - {human.status === "Online" && ( - - )} + {human.status} @@ -577,7 +585,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..9d444155 --- /dev/null +++ b/frontend/lib/humans.ts @@ -0,0 +1,64 @@ +export type HumanStatus = "Online" | "Offline" + +export interface HumanMember { + id: string + userId: number + name: string + status: HumanStatus + isCurrentUser: boolean +} + +const HUMAN_ID_PREFIX = "human:" +const HUMANS_CACHE_KEY = "aoc_humans_cache_v2" + +export function toHumanCardId(userId: number): string { + return `${HUMAN_ID_PREFIX}${userId}` +} + +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" && + (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. + } +} diff --git a/frontend/lib/server/auth.ts b/frontend/lib/server/auth.ts new file mode 100644 index 00000000..b5d13ab6 --- /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(maxAgeSeconds: number = ACCESS_TOKEN_TTL_SECONDS) { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + maxAge: maxAgeSeconds, + } +} diff --git a/frontend/lib/server/keycloak.ts b/frontend/lib/server/keycloak.ts new file mode 100644 index 00000000..8fadeec7 --- /dev/null +++ b/frontend/lib/server/keycloak.ts @@ -0,0 +1,169 @@ +import { createHash, randomBytes } from "node:crypto" +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 const KEYCLOAK_LOGIN_ATTEMPT_COOKIE_NAME = "aoc_kc_login_attempts" + +export interface KeycloakWebConfig { + authority: string + clientId: string + 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() + return first && first.length > 0 ? first : null +} + +export function getKeycloakWebConfig(): KeycloakWebConfig | null { + const authority = process.env.KEYCLOAK_AUTHORITY?.trim().replace(/\/+$/, "") ?? "" + const clientId = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "" + const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET?.trim() ?? null + + if (!authority || !clientId) { + return null + } + + return { + authority, + clientId, + clientSecret: clientSecret && clientSecret.length > 0 ? clientSecret : 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 + + const url = new URL("/api/auth/keycloak/callback", getPublicRequestOrigin(req)) + return url.toString() +} + +export function getPublicRequestOrigin(req: NextRequest): string { + const configured = process.env.PUBLIC_APP_URL?.trim().replace(/\/+$/, "") + if (configured) return configured + + const configuredCallbackUrl = process.env.KEYCLOAK_CALLBACK_URL?.trim() + if (configuredCallbackUrl) { + try { + return new URL(configuredCallbackUrl).origin + } catch { + // Ignore invalid override and continue with header-based detection. + } + } + + // 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")) ?? + firstHeaderValue(req.headers.get("host")) + + if (forwardedProto && forwardedHost) { + return `${forwardedProto}://${forwardedHost}` + } + + return req.nextUrl.origin +} + +export function toSafeNextPath(next: string | null): string { + if (!next) return "/" + if (!next.startsWith("/") || next.startsWith("//")) return "/" + return next +} + +export function base64UrlEncode(value: Buffer): string { + return value + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +export function createPkcePair() { + const verifier = base64UrlEncode(randomBytes(32)) + const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest()) + + return { verifier, challenge } +} + +export function createRandomState() { + return base64UrlEncode(randomBytes(24)) +} + +export function getTransientAuthCookieOptions() { + const isProduction = process.env.NODE_ENV === "production" + return { + httpOnly: true, + 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: isProduction, + sameSite: (isProduction ? "none" : "lax") as "none" | "lax", + path: "/", + 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 +} diff --git a/frontend/proxy.ts b/frontend/proxy.ts new file mode 100644 index 00000000..67c47d6b --- /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 === "/api/auth" || 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 + req.nextUrl.search) + return NextResponse.redirect(loginUrl) + } + + return NextResponse.next() +} + +export const config = { + matcher: ["/((?!_next/static|_next/image).*)"], +}