fix: CORS, JWT auth, useBlocker crash, and auto-migration for production deploy
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled

- Add CloudFront origin to API Gateway CORS preflight and .NET CORS policy
- Replace HttpMethod.ANY with explicit methods so OPTIONS preflight doesn't
  hit the JWT authorizer (was causing 403 on all API calls)
- Return Cognito ID token instead of access token from auth callback
  (access tokens lack the aud claim required by API Gateway JWT authorizer)
- Add CloudFront callback URI to allowed redirect list
- Remove identity_provider=Google from login URL to show Cognito hosted UI
- Replace useBlocker (requires data router) with state-based navigation guard
  to fix crash on AdminWorkspace with BrowserRouter
- Add auto-migration on Lambda cold start
- Enable Swagger in production
This commit is contained in:
Adam Moussa 2026-05-27 19:20:29 -04:00
parent da783d48cd
commit ae3ad9d823
5 changed files with 56 additions and 25 deletions

View file

@ -37,6 +37,7 @@ public class AuthController : ControllerBase
var allowedRedirectUris = new HashSet<string>(StringComparer.OrdinalIgnoreCase) var allowedRedirectUris = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ {
"https://proposals.seahaven.com/callback", "https://proposals.seahaven.com/callback",
"https://d2yevct5e5uuz5.cloudfront.net/callback",
}; };
if (_config.GetValue<bool>("Auth:DevMode")) if (_config.GetValue<bool>("Auth:DevMode"))
allowedRedirectUris.Add("http://localhost:5173/callback"); allowedRedirectUris.Add("http://localhost:5173/callback");
@ -136,7 +137,7 @@ public class AuthController : ControllerBase
user.Email, user.Email,
user.DisplayName, user.DisplayName,
user.Role.ToString(), user.Role.ToString(),
tokenResponse.AccessToken tokenResponse.IdToken
)); ));
} }

View file

@ -205,7 +205,7 @@ builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
{ {
var origins = new List<string> { "https://proposals.seahaven.com" }; var origins = new List<string> { "https://proposals.seahaven.com", "https://d2yevct5e5uuz5.cloudfront.net" };
if (builder.Environment.IsDevelopment()) if (builder.Environment.IsDevelopment())
origins.Add("http://localhost:5173"); origins.Add("http://localhost:5173");
policy.WithOrigins(origins.ToArray()) policy.WithOrigins(origins.ToArray())
@ -221,11 +221,8 @@ var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>(); app.UseMiddleware<GlobalExceptionHandler>();
if (!app.Environment.IsProduction()) app.UseSwagger();
{ app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Proposal System API v1"));
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Proposal System API v1"));
}
app.UseCors(); app.UseCors();
app.UseMiddleware<InternalApiKeyMiddleware>(); app.UseMiddleware<InternalApiKeyMiddleware>();
@ -243,4 +240,10 @@ app.Use(async (context, next) =>
app.MapControllers(); app.MapControllers();
app.MapHealthChecks("/api/health"); app.MapHealthChecks("/api/health");
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProposalDbContext>();
db.Database.Migrate();
}
app.Run(); app.Run();

View file

@ -307,6 +307,7 @@ export class ComputeStack extends cdk.Stack {
corsPreflight: { corsPreflight: {
allowOrigins: [ allowOrigins: [
'https://proposals.seahaven.com', 'https://proposals.seahaven.com',
'https://d2yevct5e5uuz5.cloudfront.net',
'http://localhost:5173', 'http://localhost:5173',
], ],
allowMethods: [ allowMethods: [
@ -364,13 +365,31 @@ export class ComputeStack extends cdk.Stack {
httpApi.addRoutes({ httpApi.addRoutes({
path: '/api/auth/{proxy+}', path: '/api/auth/{proxy+}',
methods: [apigatewayv2.HttpMethod.POST], methods: [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.OPTIONS],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/swagger/{proxy+}',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/swagger',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration, integration: apiIntegration,
}); });
httpApi.addRoutes({ httpApi.addRoutes({
path: '/{proxy+}', path: '/{proxy+}',
methods: [apigatewayv2.HttpMethod.ANY], methods: [
apigatewayv2.HttpMethod.GET,
apigatewayv2.HttpMethod.POST,
apigatewayv2.HttpMethod.PUT,
apigatewayv2.HttpMethod.DELETE,
apigatewayv2.HttpMethod.PATCH,
],
integration: apiIntegration, integration: apiIntegration,
authorizer: jwtAuthorizer, authorizer: jwtAuthorizer,
}); });

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate, useBlocker } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { import {
Box, Box,
@ -109,13 +109,17 @@ export default function AdminWorkspace() {
return () => window.removeEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler);
}, [dirty]); }, [dirty]);
const blocker = useBlocker( const [navBlocked, setNavBlocked] = useState(false);
useCallback( const pendingNavRef = useRef<string | null>(null);
({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) =>
dirty && currentLocation.pathname !== nextLocation.pathname, const guardedNavigate = useCallback((to: string) => {
[dirty], if (dirty) {
), pendingNavRef.current = to;
); setNavBlocked(true);
} else {
navigate(to);
}
}, [dirty, navigate]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -258,7 +262,7 @@ export default function AdminWorkspace() {
> >
Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'} Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
</Alert> </Alert>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}> <Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
Back to Dashboard Back to Dashboard
</Button> </Button>
</Box> </Box>
@ -269,7 +273,7 @@ export default function AdminWorkspace() {
return ( return (
<Box> <Box>
<Typography color="error">Proposal not found</Typography> <Typography color="error">Proposal not found</Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}> <Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
Back to Dashboard Back to Dashboard
</Button> </Button>
</Box> </Box>
@ -305,7 +309,7 @@ export default function AdminWorkspace() {
<Link <Link
component="button" component="button"
underline="hover" underline="hover"
onClick={() => navigate('/admin')} onClick={() => guardedNavigate('/admin')}
sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }} sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }}
> >
Admin Queue Admin Queue
@ -550,14 +554,19 @@ export default function AdminWorkspace() {
</Card> </Card>
{/* Unsaved Changes Navigation Guard */} {/* Unsaved Changes Navigation Guard */}
<Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()}> <Dialog open={navBlocked} onClose={() => setNavBlocked(false)}>
<DialogTitle>Unsaved Changes</DialogTitle> <DialogTitle>Unsaved Changes</DialogTitle>
<DialogContent> <DialogContent>
<Typography>You have unsaved changes. Are you sure you want to leave?</Typography> <Typography>You have unsaved changes. Are you sure you want to leave?</Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => blocker.reset?.()}>Stay</Button> <Button onClick={() => setNavBlocked(false)}>Stay</Button>
<Button variant="contained" color="error" onClick={() => blocker.proceed?.()}> <Button variant="contained" color="error" onClick={() => {
setNavBlocked(false);
const dest = pendingNavRef.current ?? '/admin';
pendingNavRef.current = null;
navigate(dest);
}}>
Discard & Leave Discard & Leave
</Button> </Button>
</DialogActions> </DialogActions>

View file

@ -20,7 +20,6 @@ function buildLoginUrl(): string {
response_type: 'code', response_type: 'code',
scope: 'openid email profile', scope: 'openid email profile',
redirect_uri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
identity_provider: 'Google',
}); });
return `https://${COGNITO_DOMAIN}/oauth2/authorize?${params.toString()}`; return `https://${COGNITO_DOMAIN}/oauth2/authorize?${params.toString()}`;
} }