Ear training for recognizing musical intervals — a Vite/React frontend with an ASP.NET Core API, orchestrated by .NET Aspire. The app requires a signed-in account; sign-in is via Google or X (social login).
apphost/ Aspire AppHost (project-based) — orchestrates api, web, postgres
api/Intervals.Api/ ASP.NET Core minimal API — auth, sessions, status
Auth/ Cookie auth, Google/X providers, account & session endpoints
Data/ EF Core model (AppUser, ExternalLogin, AuthEvent) + migrations
src/ Vite + React training app
auth/ Auth shell: AuthProvider, LoginPage, ProtectedApp, AccountBar
tests/
Intervals.Api.Tests/ API integration tests (WebApplicationFactory + Testcontainers.PostgreSql)
Intervals.AppHost.Tests/ Aspire distributed smoke tests (Aspire.Hosting.Testing)
docs/ Project documentation
- .NET 10 SDK (
10.0.301or later) — for the API and AppHost - Node.js 20+ and npm — for the React frontend
- Docker — Aspire runs PostgreSQL in a container during local development
- Aspire CLI (
aspire) — comes with the .NET Aspire workload; verify withaspire --version
npm install
dotnet restore # restores api, apphost, and test projectsThe repo ships a devcontainer. Canonical names:
devpod up .
devpod ssh intervals # or: docker exec -it intervals-devcontainer bashThe canonical way to run everything (PostgreSQL + API + web frontend) is Aspire:
aspire runOpen the dashboard URL printed at startup. The web resource is the React app;
the api resource is the backend. The API auto-applies EF Core migrations on
startup and Aspire injects the PostgreSQL connection string.
The frontend talks to the API through the same origin via the Vite dev proxy,
which forwards /api and /auth to the api resource.
npm run dev # frontend only (Vite, port 5173)
dotnet run --project api/Intervals.Api # API only (needs ConnectionStrings:intervalsdb)The app requires authentication. Anonymous requests to /api/* return 401.
Login uses Google and X OAuth. Each provider needs its own credentials,
stored in .NET user secrets (never committed). The app runs without them, but
the login buttons will not complete a real provider flow until they are set.
| Provider | User-secrets key | Where it comes from |
|---|---|---|
Authentication:Google:ClientId |
Google Cloud Console — OAuth client ID | |
Authentication:Google:ClientSecret |
Google Cloud Console — OAuth client secret | |
| X | Authentication:X:ClientId |
X developer portal — OAuth 2.0 client id |
| X | Authentication:X:ClientSecret |
X developer portal — OAuth 2.0 client secret |
The PostgreSQL password (
Parameters:postgres-password) is auto-generated and auto-persisted by Aspire into the AppHost's user secrets — you do not set it.
At each provider, register the API callback URI. It is the HTTP endpoint URL of
the api resource (shown in the Aspire dashboard when the app is running) plus
the callback path:
http://localhost:<api-port>/auth/callback/google # Google
http://localhost:<api-port>/auth/callback/x # X
Find the current <api-port>:
aspire describe --format Json \
| python3 -c "import sys,json;d=json.load(sys.stdin);print([r['urls'][0]['url'] for r in d['resources'] if r.get('resourceType')=='Project'][0])"The port can change between runs. To keep redirect URIs stable, pin the API HTTP
port in apphost/Program.cs:
.WithHttpEndpoint(name: "http", port: 5199, targetPort: 8080)- Google Cloud Console → APIs & Services → OAuth consent screen → External, add yourself under Test users.
- Credentials → Create credentials → OAuth client ID → Web application.
- Add
http://localhost:<api-port>/auth/callback/googleto Authorized redirect URIs. - Copy the Client ID and Client Secret.
- X developer portal → your app → User authentication settings.
- Enable OAuth 2.0 (Web App) and turn on Authorize apps with PKCE
(the app uses PKCE). Scopes used:
users.read,users.email. - Set the redirect URI to
http://localhost:<api-port>/auth/callback/x. - Set the Website URL to a complete public
https://URL, such as the production/staging site or public project page. X may rejectlocalhosthere even when the callback URI is local. - Copy the OAuth 2.0 Client ID and Client Secret. (X email availability varies, so the app treats email as optional and keys on the stable X user id.)
PROJECT=api/Intervals.Api
dotnet user-secrets set "Authentication:Google:ClientId" "<google-client-id>" --project $PROJECT
dotnet user-secrets set "Authentication:Google:ClientSecret" "<google-client-secret>" --project $PROJECT
dotnet user-secrets set "Authentication:X:ClientId" "<x-client-id>" --project $PROJECT
dotnet user-secrets set "Authentication:X:ClientSecret" "<x-client-secret>" --project $PROJECTVerify: dotnet user-secrets list --project api/Intervals.Api
Secrets are read at API startup — restart Aspire after changing them
(aspire stop then aspire run).
- A provider whose
ClientIdis empty is not registered; its login button redirects back to/login?auth=unknown. - A
ClientIdstill set to the dummy placeholder starts the provider challenge but is rejected — e.g. Google showsError 401: invalid_client/ "The OAuth client was not found". Replace it with a real client id from the provider console.
npm test # frontend (Vitest)
dotnet test tests/Intervals.Api.Tests # API integration (Testcontainers Postgres)
dotnet test tests/Intervals.AppHost.Tests # Aspire distributed smoke- Frontend tests run against jsdom with mocked
fetch. - API integration tests spin up a disposable PostgreSQL container, so Docker must be running. They cover anonymous rejection, authenticated session/status, account creation and reuse, no email auto-merge, auth-event audit, and logout.
- The distributed test launches the real AppHost (PostgreSQL + API) and asserts the auth boundary end-to-end.
npm run dev # Vite dev server (host 0.0.0.0, port 5173)
npm run build # type-check (tsc -b) + production build to dist/
npm run preview # preview the production build
npm run test # run Vitest once
npm run test:watch28P01: password authentication failed for user "postgres"— a stale persistent PostgreSQL volume holds a different password than the current run. Remove the volume and let Aspire recreate it:docker volume rm intervals-postgres-data, thenaspire runagain.invalid_clientfrom Google/X — provider secret is missing or still the dummy placeholder; see Authentication & required provider secrets.- Login works but redirects to the wrong origin —
Web__BaseUrlis injected by the AppHost in run mode; if you run the API outside Aspire, set it yourself.
Before a public launch: move provider secrets from user secrets into the hosting
platform's managed secret store, register the production-origin callback URIs
(https://<origin>/auth/callback/google, .../x), persist ASP.NET Core
data-protection keys, and run the API behind HTTPS with forwarded headers
configured.