Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
da1fb13
feat: add jwt auth flow and enable test1 deploy
Feb 21, 2026
398be82
fix: server-render humans list to prevent refresh flicker
Feb 21, 2026
3a68f98
fix: keep humans list stable across page reload
Feb 21, 2026
9a2ab83
feat: add brevo email verification flow for signup
Feb 21, 2026
c2c0dfd
Merge branch 'develop' into add-autorization
Gomering1 Feb 21, 2026
a87c18a
fix: address major PR review findings
Feb 21, 2026
39f237c
fix: resolve remaining minor PR review findings
Feb 21, 2026
285c2e4
fix: harden secret config defaults and signup challenge validation
Feb 22, 2026
240d982
merge: baseline auth flow before keycloak integration
Feb 22, 2026
e324a13
Integrate Keycloak OIDC auth flow for test1 deploy
Feb 22, 2026
46ed52c
Fix Keycloak callback origin behind proxy
Feb 22, 2026
7162832
Harden Keycloak callback URL and exchange error handling
Feb 22, 2026
dfbc7c4
Fix Keycloak exchange errors and SQLite migration
Feb 22, 2026
3bf151d
Disable legacy auth and enforce Keycloak-only UI flow
Feb 22, 2026
27fae77
Streamline auth redirects and switch Keycloak hostname to auth
Feb 22, 2026
103b864
Limit signup bootstrap cookie relay to aoc domains
Feb 22, 2026
9a6c8a2
Map Keycloak realm by deploy environment
Feb 22, 2026
7ef8452
Filter Humans list to Keycloak-linked users
Feb 23, 2026
3c1c1a6
Add Keycloak login mode toggles and Entra SSO flags
Feb 23, 2026
19376f9
Fix Keycloak logout flow and persist id token hint
Feb 23, 2026
2bc32fa
Harden auth redirects and add OIDC loop guard
Feb 23, 2026
07c4ca9
Fix missing auth loop cookie on login redirect
Feb 23, 2026
a890d24
Set test1 to Entra-only auth mode
Feb 23, 2026
3040701
Fix Keycloak login/logout redirect loops in browser auth flow
Feb 23, 2026
99aec0b
Disable caching on auth pages and Keycloak routes
Feb 23, 2026
8c95d10
Bootstrap default workspace for Keycloak users
Feb 23, 2026
0496384
Prefer Entra given/family name for Keycloak user display name
Feb 23, 2026
4099f11
Fix current user name in member panel and add CopilotKit info routes
Feb 23, 2026
6932588
Fix CopilotKit info routes for single-endpoint runtime
Feb 23, 2026
1e31d37
Proxy CopilotKit info routes to single-route runtime
Feb 23, 2026
19fc054
Force reauth for Entra-only login and shorten test1 JWT TTL
Feb 23, 2026
1d266e0
Switch API auth to Keycloak access tokens only
Feb 24, 2026
c60c82b
Remove unused legacy email verification services
Feb 24, 2026
24c1ef2
Harden Keycloak callback against redirect loops
Feb 24, 2026
0da3d8d
Stabilize OIDC transient cookies for Entra callback flow
Feb 24, 2026
9e8b60d
fix(auth): resolve infinite login loop and improve Entra SSO flow
Feb 25, 2026
c42dcd7
fix(build): remove deprecated middleware.ts for Next.js 16 proxy conv…
Feb 25, 2026
1ee9c8c
fix: address all critical and major CodeRabbit review findings
Feb 25, 2026
5d98028
fix: address hidden CodeRabbit review findings
Feb 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 79 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
AZURE_OPENAI_API_KEY=
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
26 changes: 22 additions & 4 deletions backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/src/Api/Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
<ItemGroup>
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="3.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Hosting.AGUI.AspNetCore" Version="1.0.0-preview.260212.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
Expand Down
22 changes: 22 additions & 0 deletions backend/src/Api/Auth/AuthenticatedUserExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
61 changes: 61 additions & 0 deletions backend/src/Api/Auth/KeycloakAppUserSyncMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Security.Claims;

namespace AzureOpsCrew.Api.Auth;

public sealed class KeycloakAppUserSyncMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<KeycloakAppUserSyncMiddleware> _logger;

public KeycloakAppUserSyncMiddleware(RequestDelegate next, ILogger<KeycloakAppUserSyncMiddleware> 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);
}
}
Loading