Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .github/workflows/_deploy-infrastructure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ jobs:

- name: Plan Cluster Resources
id: deploy_cluster
env:
GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }}
GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
run: bash ./cloud-infrastructure/cluster/deploy-cluster.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location }} ${{ inputs.cluster_location_acronym }} ${{ inputs.sql_admin_object_id }} ${{ inputs.domain_name }} --plan

- name: Show DNS Configuration
Expand Down Expand Up @@ -137,6 +140,9 @@ jobs:

- name: Deploy Cluster Resources
id: deploy_cluster
env:
GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }}
GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
run: bash ./cloud-infrastructure/cluster/deploy-cluster.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location }} ${{ inputs.cluster_location_acronym }} ${{ inputs.sql_admin_object_id }} ${{ inputs.domain_name }} --apply

- name: Refresh Azure Tokens # The previous step may take a while, so we refresh the token to avoid timeouts
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,32 @@ Once the Aspire dashboard fully loads, click to the WebApp and sign up for a new

<img src="https://platformplatformgithub.blob.core.windows.net/$root/local-development-exp.gif" alt="Getting Started" title="Developer Experience" width="800"/>

### (Optional) Set up Google OAuth for "Sign in with Google"

PlatformPlatform supports authentication via Google OAuth. This is optional for local development since email-based one-time passwords work without any configuration. When running locally without Google OAuth credentials configured, the Aspire dashboard prompts for parameters -- enter `not-configured` to skip and start Aspire without Google OAuth.

<details>

<summary>Google Cloud Console setup</summary>

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project (e.g., "YourProduct OAuth")
3. Navigate to **APIs & Services** > **Credentials**
4. Configure OAuth consent screen (first time only):
- App name, support email, audience (External), contact info
- Agree to Google API Services: User Data Policy
5. Create OAuth client ID:
- Application type: "Web application"
- Name: "YourProduct Localhost"
6. Add Authorized redirect URIs:
- `https://localhost:9000/api/account-management/authentication/Google/login/callback`
- `https://localhost:9000/api/account-management/authentication/Google/signup/callback`
7. Note the Client ID and Client Secret

</details>

**Aspire parameter configuration**: Click **Parameters** in the Aspire dashboard and enter your Google OAuth Client ID and Client Secret. These values are stored securely in .NET user secrets and persist across restarts.

## 4. Set up CI/CD with passwordless deployments from GitHub to Azure

Run this command to automate Azure Subscription configuration and set up [GitHub Workflows](https://github.com/platformplatform/PlatformPlatform/actions) for deploying [Azure Infrastructure](./cloud-infrastructure) (using Bicep) and compiling [application code](./application) to Docker images deployed to Azure Container Apps:
Expand All @@ -219,6 +245,20 @@ The infrastructure is configured with auto-scaling and hosting costs in focus. I

![Azure Costs](https://platformplatformgithub.blob.core.windows.net/$root/azure-costs-center.png)

### (Optional) Configure Google OAuth for staging and production

If you set up Google OAuth locally, use the Developer CLI to store your Google OAuth credentials as GitHub secrets for deployment to Azure Key Vault:

```bash
pp github-config
```

Remember to add redirect URIs for each environment in your Google Cloud Console configuration, e.g.:
- `https://staging.yourproduct.com/api/account-management/authentication/Google/login/callback`
- `https://staging.yourproduct.com/api/account-management/authentication/Google/signup/callback`
- `https://app.yourproduct.com/api/account-management/authentication/Google/login/callback`
- `https://app.yourproduct.com/api/account-management/authentication/Google/signup/callback`

# Experimental: Agentic Workflow with Claude Code

PlatformPlatform includes a multi-agent autonomous development workflow powered by [Claude Code](https://claude.com/product/claude-code). Nine specialized AI agents collaborate to deliver complete features, from requirements to production-ready code, while enforcing enterprise-grade quality standards.
Expand Down
50 changes: 28 additions & 22 deletions application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Authentication.TokenSigning;
Expand All @@ -17,6 +16,8 @@
private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens";
private const string UnauthorizedReasonItemKey = "UnauthorizedReason";

private static readonly JsonWebTokenHandler TokenHandler = new();

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenCookieValue))
Expand All @@ -38,8 +39,9 @@

// For non-API requests (SPA routes): delete cookies and let the page load
// The SPA will load without auth and redirect to login as needed
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
var hostCookieOptions = new CookieOptions { Secure = true };

Check warning on line 42 in application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure creating this cookie without the "HttpOnly" flag is safe. (https://rules.sonarsource.com/csharp/RSPEC-3330)

Check warning on line 42 in application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure creating this cookie without the "HttpOnly" flag is safe. (https://rules.sonarsource.com/csharp/RSPEC-3330)
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions);
}

await next(context);
Expand All @@ -49,13 +51,13 @@
{
logger.LogDebug("Refreshing authentication tokens as requested by endpoint");
var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshTokenCookieValue!);
ReplaceAuthenticationHeaderWithCookie(context, refreshToken, accessToken);
await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken);
context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey);
}
else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) &&
context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken))
{
ReplaceAuthenticationHeaderWithCookie(context, refreshToken.Single()!, accessToken.Single()!);
await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!);
}
}

Expand All @@ -70,12 +72,13 @@

try
{
if (accessToken is null || ExtractExpirationFromToken(accessToken) < timeProvider.GetUtcNow())
if (accessToken is null || await ExtractExpirationFromTokenAsync(accessToken) < timeProvider.GetUtcNow())
{
if (ExtractExpirationFromToken(refreshToken) < timeProvider.GetUtcNow())
if (await ExtractExpirationFromTokenAsync(refreshToken) < timeProvider.GetUtcNow())
{
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
var expiredCookieOptions = new CookieOptions { Secure = true };

Check warning on line 79 in application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure creating this cookie without the "HttpOnly" flag is safe. (https://rules.sonarsource.com/csharp/RSPEC-3330)

Check warning on line 79 in application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure creating this cookie without the "HttpOnly" flag is safe. (https://rules.sonarsource.com/csharp/RSPEC-3330)
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, expiredCookieOptions);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, expiredCookieOptions);
logger.LogDebug("The refresh-token has expired; authentication token cookies are removed");
return;
}
Expand All @@ -85,7 +88,7 @@
(refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshToken);

// Update the authentication token cookies with the new tokens
ReplaceAuthenticationHeaderWithCookie(context, refreshToken, accessToken);
await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken);
}

context.Request.Headers.Authorization = $"Bearer {accessToken}";
Expand Down Expand Up @@ -164,13 +167,14 @@
return;
}

context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
var hostCookieOptions = new CookieOptions { Secure = true };

Check warning on line 170 in application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure creating this cookie without the "HttpOnly" flag is safe. (https://rules.sonarsource.com/csharp/RSPEC-3330)

Check warning on line 170 in application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure creating this cookie without the "HttpOnly" flag is safe. (https://rules.sonarsource.com/csharp/RSPEC-3330)
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions);
}

private void ReplaceAuthenticationHeaderWithCookie(HttpContext context, string refreshToken, string accessToken)
private async Task ReplaceAuthenticationHeaderWithCookieAsync(HttpContext context, string refreshToken, string accessToken)
{
var refreshTokenExpires = ExtractExpirationFromToken(refreshToken);
var refreshTokenExpires = await ExtractExpirationFromTokenAsync(refreshToken);

// The refresh token cookie is SameSiteMode.Lax, which makes the cookie available on the first request when redirected
// from another site. This means we can redirect to the login page if the user is not authenticated without
Expand All @@ -188,11 +192,9 @@
context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey);
}

private DateTimeOffset ExtractExpirationFromToken(string token)
private async Task<DateTimeOffset> ExtractExpirationFromTokenAsync(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();

if (!tokenHandler.CanReadToken(token))
if (!TokenHandler.CanReadToken(token))
{
throw new SecurityTokenMalformedException("The token is not a valid JWT.");
}
Expand All @@ -202,11 +204,15 @@
clockSkew: TimeSpan.FromSeconds(2) // In Azure, we don't need any clock skew, but this must be a lower value than in downstream APIs
);

// This will throw if the token is invalid
var tokenClaims = tokenHandler.ValidateToken(token, validationParameters, out _);
var validationResult = await TokenHandler.ValidateTokenAsync(token, validationParameters);

if (!validationResult.IsValid)
{
throw validationResult.Exception;
}

// The 'exp' claim is the number of seconds since Unix epoch (00:00:00 UTC on 1st January 1970)
var expires = tokenClaims.FindFirstValue(JwtRegisteredClaimNames.Exp)!;
var expires = validationResult.Claims[JwtRegisteredClaimNames.Exp]?.ToString()!;

return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires));
}
Expand Down
8 changes: 8 additions & 0 deletions application/AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

SecretManagerHelper.GenerateAuthenticationTokenSigningKey("authentication-token-signing-key");

var googleOAuthClientId = builder.AddParameter("google-oauth-client-id", true)
.WithDescription("Google OAuth Client ID from [Google Cloud Console](https://console.cloud.google.com/apis/credentials). See README.md for setup instructions. Enter `not-configured` to skip Google OAuth.", true);
var googleOAuthClientSecret = builder.AddParameter("google-oauth-client-secret", true)
.WithDescription("Google OAuth Client Secret from [Google Cloud Console](https://console.cloud.google.com/apis/credentials). See README.md for setup instructions. Enter `not-configured` to skip Google OAuth.", true);

var sqlPassword = builder.CreateStablePassword("sql-server-password");
var sqlServer = builder.AddSqlServer("sql-server", sqlPassword, 9002)
.WithDataVolume("platform-platform-sql-server-data")
Expand Down Expand Up @@ -65,6 +70,9 @@
.WithUrlConfiguration("/account-management")
.WithReference(accountManagementDatabase)
.WithReference(azureStorage)
.WithEnvironment("OAuth__Google__ClientId", googleOAuthClientId)
.WithEnvironment("OAuth__Google__ClientSecret", googleOAuthClientSecret)
.WithEnvironment("OAuth__AllowMockProvider", "true")
.WaitFor(accountManagementWorkers);

var backOfficeDatabase = sqlServer
Expand Down
1 change: 1 addition & 0 deletions application/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageVersion Include="Aspire.Hosting.SqlServer" Version="13.1.0" />
<PackageVersion Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="13.1.0" />
<PackageVersion Include="Azure.Communication.Email" Version="1.1.0" />
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.4.0" />
<PackageVersion Include="Azure.Security.KeyVault.Keys" Version="4.8.0" />
<PackageVersion Include="Azure.Security.KeyVault.Secrets" Version="4.8.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
using PlatformPlatform.AccountManagement.Features.Authentication.Commands;
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
using PlatformPlatform.AccountManagement.Features.Authentication.Queries;
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands;
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
using PlatformPlatform.SharedKernel.ApiResults;
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
using PlatformPlatform.SharedKernel.Endpoints;
Expand All @@ -17,18 +14,6 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(RoutesPrefix).WithTags("Authentication").RequireAuthorization().ProducesValidationProblem();

group.MapPost("/login/start", async Task<ApiResult<StartLoginResponse>> (StartLoginCommand command, IMediator mediator)
=> await mediator.Send(command)
).Produces<StartLoginResponse>().AllowAnonymous();

group.MapPost("/login/{id}/complete", async Task<ApiResult> (LoginId id, CompleteLoginCommand command, IMediator mediator)
=> await mediator.Send(command with { Id = id })
).AllowAnonymous();

group.MapPost("/login/{emailConfirmationId}/resend-code", async Task<ApiResult<ResendEmailConfirmationCodeResponse>> (EmailConfirmationId emailConfirmationId, IMediator mediator)
=> await mediator.Send(new ResendEmailConfirmationCodeCommand { Id = emailConfirmationId })
).Produces<ResendEmailConfirmationCodeResponse>().AllowAnonymous();

group.MapPost("/logout", async Task<ApiResult> (IMediator mediator)
=> await mediator.Send(new LogoutCommand())
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands;
using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
using PlatformPlatform.SharedKernel.ApiResults;
using PlatformPlatform.SharedKernel.Endpoints;

namespace PlatformPlatform.AccountManagement.Api.Endpoints;

public sealed class EmailAuthenticationEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/account-management/authentication/email";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(RoutesPrefix).WithTags("EmailAuthentication").RequireAuthorization().ProducesValidationProblem();

group.MapPost("/login/start", async Task<ApiResult<StartEmailLoginResponse>> (StartEmailLoginCommand command, IMediator mediator)
=> await mediator.Send(command)
).Produces<StartEmailLoginResponse>().AllowAnonymous();

group.MapPost("/login/{id}/complete", async Task<ApiResult> (EmailLoginId id, CompleteEmailLoginCommand command, IMediator mediator)
=> await mediator.Send(command with { Id = id })
).AllowAnonymous();

group.MapPost("/login/{id}/resend-code", async Task<ApiResult<ResendEmailLoginCodeResponse>> (EmailLoginId id, IMediator mediator)
=> await mediator.Send(new ResendEmailLoginCodeCommand { Id = id })
).Produces<ResendEmailLoginCodeResponse>().AllowAnonymous();

group.MapPost("/signup/start", async Task<ApiResult<StartEmailSignupResponse>> (StartEmailSignupCommand command, IMediator mediator)
=> await mediator.Send(command)
).Produces<StartEmailSignupResponse>().AllowAnonymous();

group.MapPost("/signup/{id}/complete", async Task<ApiResult> (EmailLoginId id, CompleteEmailSignupCommand command, IMediator mediator)
=> await mediator.Send(command with { EmailLoginId = id })
).AllowAnonymous();

group.MapPost("/signup/{id}/resend-code", async Task<ApiResult<ResendEmailLoginCodeResponse>> (EmailLoginId id, IMediator mediator)
=> await mediator.Send(new ResendEmailLoginCodeCommand { Id = id })
).Produces<ResendEmailLoginCodeResponse>().AllowAnonymous();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Commands;
using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain;
using PlatformPlatform.SharedKernel.ApiResults;
using PlatformPlatform.SharedKernel.Endpoints;

namespace PlatformPlatform.AccountManagement.Api.Endpoints;

public sealed class ExternalAuthenticationEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/account-management/authentication";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(RoutesPrefix).WithTags("ExternalAuthentication").RequireAuthorization().ProducesValidationProblem();

group.MapGet("/{provider}/login/start", async Task<ApiResult<string>> (ExternalProviderType provider, [AsParameters] StartExternalLoginCommand command, IMediator mediator)
=> await mediator.Send(command with { ProviderType = provider })
).AllowAnonymous();

group.MapGet("/{provider}/login/callback", async Task<ApiResult<string>> (ExternalProviderType provider, string? code, string? state, string? error, [FromQuery(Name = "error_description")] string? errorDescription, IMediator mediator)
=> await mediator.Send(new CompleteExternalLoginCommand(code, state, error, errorDescription) { Provider = provider.ToString() })
).AllowAnonymous();

group.MapGet("/{provider}/signup/start", async Task<ApiResult<string>> (ExternalProviderType provider, IMediator mediator)
=> await mediator.Send(new StartExternalSignupCommand { ProviderType = provider })
).AllowAnonymous();

group.MapGet("/{provider}/signup/callback", async Task<ApiResult<string>> (ExternalProviderType provider, string? code, string? state, string? error, [FromQuery(Name = "error_description")] string? errorDescription, IMediator mediator)
=> await mediator.Send(new CompleteExternalSignupCommand(code, state, error, errorDescription) { Provider = provider.ToString() })
).AllowAnonymous();
}
}
Loading
Loading