Phase 2: Authentication scaffolding

- Add JWT Bearer auth to ASP.NET (authority/audience from AUTH__AUTHORITY / AUTH__AUDIENCE config)
- Add KnownUserMiddleware: upserts KnownUser and resolves pending shares on each authenticated request
- Add MeController as a guarded test endpoint (/api/me)
- Add oidc-client-ts + react-router-dom to client
- Create AuthContext/AuthProvider with login, logout, token storage
- Create AuthGuard component protecting all budget routes
- Add stub /callback page for OIDC redirect handling
- Wire up all routes in App.tsx with SPA routing structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-04-25 07:54:21 -05:00
parent d788dfea03
commit ae21da6a81
16 changed files with 330 additions and 119 deletions
+1
View File
@@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Budget.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class MeController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
var sub = User.FindFirst("sub")?.Value;
var email = User.FindFirst("email")?.Value;
return Ok(new { sub, email });
}
}
+23
View File
@@ -1,5 +1,8 @@
using Budget.Api.Data; using Budget.Api.Data;
using Budget.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -11,6 +14,21 @@ var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
$"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}"; $"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}";
builder.Services.AddDbContext<AppDbContext>(opt => opt.UseNpgsql(connStr)); builder.Services.AddDbContext<AppDbContext>(opt => opt.UseNpgsql(connStr));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["AUTH__AUTHORITY"];
options.Audience = builder.Configuration["AUTH__AUDIENCE"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers(); builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
@@ -18,6 +36,11 @@ var app = builder.Build();
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<KnownUserMiddleware>();
app.MapControllers(); app.MapControllers();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
@@ -0,0 +1,48 @@
using Budget.Api.Data;
using Budget.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace Budget.Api.Services;
public class KnownUserMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, AppDbContext db)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var sub = context.User.FindFirst("sub")?.Value;
var email = context.User.FindFirst("email")?.Value;
var name = context.User.FindFirst("name")?.Value;
if (sub != null && email != null && name != null)
{
var known = await db.KnownUsers.FindAsync(sub);
if (known is null)
{
db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow });
}
else
{
known.Email = email;
known.Name = name;
known.LastSeenAt = DateTimeOffset.UtcNow;
}
// Resolve pending shares for this user's email
var pending = await db.BudgetShares
.Where(s => s.IsPending && s.SharedWithEmail == email)
.ToListAsync();
foreach (var share in pending)
{
share.SharedWithUserId = sub;
share.IsPending = false;
}
await db.SaveChangesAsync();
}
}
await next(context);
}
}
+81 -1
View File
@@ -8,8 +8,10 @@
"name": "budget-client", "name": "budget-client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"oidc-client-ts": "^3.5.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -1306,6 +1308,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1837,6 +1852,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2204,6 +2228,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/oidc-client-ts": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
"integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2364,6 +2400,44 @@
"react": "^19.2.5" "react": "^19.2.5"
} }
}, },
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
@@ -2421,6 +2495,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+3 -1
View File
@@ -10,8 +10,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"oidc-client-ts": "^3.5.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
+39 -117
View File
@@ -1,122 +1,44 @@
import { useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import reactLogo from './assets/react.svg' import { AuthProvider } from './auth/AuthContext';
import viteLogo from './assets/vite.svg' import { AuthGuard } from './auth/AuthGuard';
import heroImg from './assets/hero.png' import { CallbackPage } from './pages/CallbackPage';
import './App.css' import { BudgetsPage } from './pages/BudgetsPage';
import { IncomePage } from './pages/IncomePage';
import { OutgoPage } from './pages/OutgoPage';
import { SummaryPage } from './pages/SummaryPage';
import { SettingsPage } from './pages/SettingsPage';
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<> <AuthProvider>
<section id="center"> <BrowserRouter>
<div className="hero"> <Routes>
<img src={heroImg} className="base" width="170" height="179" alt="" /> <Route path="/" element={<Navigate to="/budgets" replace />} />
<img src={reactLogo} className="framework" alt="React logo" /> <Route path="/callback" element={<CallbackPage />} />
<img src={viteLogo} className="vite" alt="Vite logo" /> <Route
</div> path="/budgets"
<div> element={<AuthGuard><BudgetsPage /></AuthGuard>}
<h1>Get started</h1> />
<p> <Route
Edit <code>src/App.tsx</code> and save to test <code>HMR</code> path="/budgets/:id/income"
</p> element={<AuthGuard><IncomePage /></AuthGuard>}
</div> />
<button <Route
type="button" path="/budgets/:id/outgo"
className="counter" element={<AuthGuard><OutgoPage /></AuthGuard>}
onClick={() => setCount((count) => count + 1)} />
> <Route
Count is {count} path="/budgets/:id/summary"
</button> element={<AuthGuard><SummaryPage /></AuthGuard>}
</section> />
<Route
<div className="ticks"></div> path="/budgets/:id/settings"
element={<AuthGuard><SettingsPage /></AuthGuard>}
<section id="next-steps"> />
<div id="docs"> </Routes>
<svg className="icon" role="presentation" aria-hidden="true"> </BrowserRouter>
<use href="/icons.svg#documentation-icon"></use> </AuthProvider>
</svg> );
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
} }
export default App export default App;
@@ -0,0 +1,58 @@
import { createContext, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { UserManager } from 'oidc-client-ts';
import type { User } from 'oidc-client-ts';
import { authConfig } from './authConfig';
interface AuthContextValue {
user: User | null;
isLoading: boolean;
login: () => void;
logout: () => void;
getToken: () => string | null;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const userManager = new UserManager(authConfig);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
userManager.getUser().then(u => {
setUser(u);
setIsLoading(false);
});
const onUserLoaded = (u: User) => setUser(u);
const onUserUnloaded = () => setUser(null);
userManager.events.addUserLoaded(onUserLoaded);
userManager.events.addUserUnloaded(onUserUnloaded);
return () => {
userManager.events.removeUserLoaded(onUserLoaded);
userManager.events.removeUserUnloaded(onUserUnloaded);
};
}, []);
const login = () => userManager.signinRedirect();
const logout = () => userManager.signoutRedirect();
const getToken = () => user?.access_token ?? null;
return (
<AuthContext.Provider value={{ user, isLoading, login, logout, getToken }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
export { userManager };
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import { useAuth } from './AuthContext';
export function AuthGuard({ children }: { children: ReactNode }) {
const { user, isLoading, login } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!user) {
login();
return null;
}
return <>{children}</>;
}
+11
View File
@@ -0,0 +1,11 @@
import type { UserManagerSettings } from 'oidc-client-ts';
export const authConfig: UserManagerSettings = {
authority: import.meta.env.VITE_AUTH_AUTHORITY,
client_id: import.meta.env.VITE_AUTH_CLIENT_ID,
redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI,
response_type: 'code',
scope: 'openid profile email',
post_logout_redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI?.replace('/callback', ''),
automaticSilentRenew: true,
};
@@ -0,0 +1,3 @@
export function BudgetsPage() {
return <div>Budgets coming soon</div>;
}
@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { userManager } from '../auth/AuthContext';
export function CallbackPage() {
const navigate = useNavigate();
useEffect(() => {
userManager.signinRedirectCallback()
.then(() => navigate('/budgets'))
.catch(err => {
console.error('OIDC callback error', err);
navigate('/');
});
}, [navigate]);
return <div>Signing in...</div>;
}
@@ -0,0 +1,3 @@
export function IncomePage() {
return <div>Income coming soon</div>;
}
@@ -0,0 +1,3 @@
export function OutgoPage() {
return <div>Outgo coming soon</div>;
}
@@ -0,0 +1,3 @@
export function SettingsPage() {
return <div>Settings coming soon</div>;
}
@@ -0,0 +1,3 @@
export function SummaryPage() {
return <div>Summary coming soon</div>;
}