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
+81 -1
View File
@@ -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",
+3 -1
View File
@@ -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
View File
@@ -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 };
+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>;
}