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:
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<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 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using Budget.Api.Data;
|
||||
using Budget.Api.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -11,6 +14,21 @@ var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
$"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}";
|
||||
|
||||
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();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -18,6 +36,11 @@ var app = builder.Build();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseMiddleware<KnownUserMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Generated
+81
-1
@@ -8,8 +8,10 @@
|
||||
"name": "budget-client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -1306,6 +1308,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1837,6 +1852,15 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -2204,6 +2228,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -2364,6 +2400,44 @@
|
||||
"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": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
@@ -2421,6 +2495,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
+39
-117
@@ -1,122 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import { AuthGuard } from './auth/AuthGuard';
|
||||
import { CallbackPage } from './pages/CallbackPage';
|
||||
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() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/budgets" replace />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
<Route
|
||||
path="/budgets"
|
||||
element={<AuthGuard><BudgetsPage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/income"
|
||||
element={<AuthGuard><IncomePage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/outgo"
|
||||
element={<AuthGuard><OutgoPage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/summary"
|
||||
element={<AuthGuard><SummaryPage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/settings"
|
||||
element={<AuthGuard><SettingsPage /></AuthGuard>}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user