diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf1668c --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +LOG_LEVEL=info + +POSTGRES_DB=stitch +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +STITCH_MIGRATOR_PASSWORD=CHANGE_ME_migrator123! +STITCH_APP_PASSWORD=CHANGE_ME_app123! + +STITCH_DB_SCHEMA_MODE="if-empty" +STITCH_DB_SEED_MODE="if-needed" +STITCH_DB_SEED_PROFILE="dev" + +FRONTEND_ORIGIN_URL=http://localhost:3000 + +# --- Auth (API) --- +# AUTH_DISABLED=true bypasses JWT validation for local dev (default). +# When false, the API validates Bearer tokens using the issuer/audience/JWKS below. +AUTH_DISABLED=true + +# For local auth testing with mock OIDC (docker compose --profile auth-test): +# AUTH_DISABLED=false +# AUTH_ISSUER=http://localhost:3100/ +# AUTH_AUDIENCE=stitch-api-local +# AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json +# +# For real Auth0: +# AUTH_ISSUER=https://.auth0.com/ +# AUTH_AUDIENCE= +# AUTH_JWKS_URI=https://.auth0.com/.well-known/jwks.json + +# --- Auth (Frontend) --- +# +# For local auth testing (localauth0 via CORS proxy on port 3100): +# VITE_AUTH0_DOMAIN=http://localhost:3100 +# localauth0 hardcodes expected client_id as the literal string "client_id" +# VITE_AUTH0_CLIENT_ID=client_id +# VITE_AUTH0_AUDIENCE=stitch-api-local +# VITE_API_URL=http://localhost:8000/api/v1 +# +# For real Auth0: +# VITE_AUTH0_DOMAIN=.auth0.com +# VITE_AUTH0_CLIENT_ID= +# VITE_AUTH0_AUDIENCE= +# VITE_API_URL=http://localhost:8000/api/v1 diff --git a/.github/workflows/docker-compose-build.yml b/.github/workflows/docker-compose-build.yml index 8074b3d..c140095 100644 --- a/.github/workflows/docker-compose-build.yml +++ b/.github/workflows/docker-compose-build.yml @@ -10,6 +10,6 @@ jobs: uses: actions/checkout@v5 - name: Set up example Env file - run: cp env.example .env + run: cp .env.example .env - run: docker compose build diff --git a/.github/workflows/docker-compose-config.yml b/.github/workflows/docker-compose-config.yml index 77d7f1b..f633036 100644 --- a/.github/workflows/docker-compose-config.yml +++ b/.github/workflows/docker-compose-config.yml @@ -10,6 +10,6 @@ jobs: uses: actions/checkout@v5 - name: Set up example Env file - run: cp env.example .env + run: cp .env.example .env - run: docker compose config diff --git a/README.md b/README.md index 8d9c356..1b04572 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,45 @@ Note: The `db-init` service runs automatically (via `depends_on`) to apply schem - `STITCH_DB_SEED_MODE` - `STITCH_DB_SEED_PROFILE` +### Auth Testing (optional) + +By default, auth is disabled (`AUTH_DISABLED=true`) — all requests get a hardcoded dev user with no token required. To test real JWT auth flows locally with a mock OIDC server: + +1. Update `.env`: + ``` + AUTH_DISABLED=false + AUTH_ISSUER=http://localauth0:3000/ + AUTH_AUDIENCE=stitch-api-local + AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json + ``` + +2. Start with the `auth-test` profile: + ```bash + docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build + ``` + +3. Get a token and make requests: + ```bash + # Health check (always open) + curl localhost:8000/api/v1/health + + # No token → 401 + curl localhost:8000/api/v1/resources/ + + # Get a valid token + TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') + + # Authenticated request → 200 + curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ + ``` + +Swagger UI (`/docs`) also supports the "Authorize" button for token entry. + +See [docs/auth-testing.md](docs/auth-testing.md) for the full scenario guide. + ## Reset (wipe DB volumes safely) Stop containers and delete the Postgres volume (this removes all local DB data): diff --git a/deployments/stitch-frontend/Dockerfile b/deployments/stitch-frontend/Dockerfile index d96dbe1..5948734 100644 --- a/deployments/stitch-frontend/Dockerfile +++ b/deployments/stitch-frontend/Dockerfile @@ -10,6 +10,10 @@ COPY index.html vite.config.js ./ COPY public/ ./public/ COPY src/ ./src/ +ARG VITE_AUTH0_DOMAIN +ARG VITE_AUTH0_CLIENT_ID +ARG VITE_AUTH0_AUDIENCE +ARG VITE_API_URL RUN npm run build ######################## diff --git a/deployments/stitch-frontend/package-lock.json b/deployments/stitch-frontend/package-lock.json index 1a23ae5..1fdce74 100644 --- a/deployments/stitch-frontend/package-lock.json +++ b/deployments/stitch-frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "stitch-frontend", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "^2.15.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", @@ -113,6 +114,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth0/auth0-auth-js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.4.0.tgz", + "integrity": "sha512-ShA7KT4KvcBEtxsXZTcrmoNxai5q1JXhB2aEBFnZD1L6LNLzzmiUWiFTtGMsaaITCylr8TJ/onEQk6XZmUHXbg==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.8", + "openid-client": "^6.8.0" + } + }, + "node_modules/@auth0/auth0-react": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.15.0.tgz", + "integrity": "sha512-LbRU87U54/YW/N3UHtNVoj3mCBBz+iYAdAByQjbXOkpI6IYnjMBwIwDusW3N23XNXq9WnihD57Dyi2R3/Q9btw==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.16.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.16.0.tgz", + "integrity": "sha512-UTP45NqjC2jVc/WaWh+iYOZt6FajpTJc+3WzljbXBiv2f76wDw4Mt9hW/aShBovsRmvKEIHaCifD3c/Gxmo2ZQ==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-auth-js": "^1.4.0", + "browser-tabs-lock": "^1.2.15", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2388,6 +2424,16 @@ "concat-map": "0.0.1" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2682,6 +2728,15 @@ "license": "MIT", "peer": true }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2729,6 +2784,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -3481,6 +3542,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3877,6 +3947,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4051,6 +4127,28 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/deployments/stitch-frontend/package.json b/deployments/stitch-frontend/package.json index 22d99f5..01b22a4 100644 --- a/deployments/stitch-frontend/package.json +++ b/deployments/stitch-frontend/package.json @@ -16,6 +16,7 @@ "test:coverage": "vitest --coverage" }, "dependencies": { + "@auth0/auth0-react": "^2.15.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", diff --git a/deployments/stitch-frontend/src/App.jsx b/deployments/stitch-frontend/src/App.jsx index 7c25b62..0568759 100644 --- a/deployments/stitch-frontend/src/App.jsx +++ b/deployments/stitch-frontend/src/App.jsx @@ -1,9 +1,13 @@ import ResourcesView from "./components/ResourcesView"; import ResourceView from "./components/ResourceView"; +import { LogoutButton } from "./components/LogoutButton"; function App() { return (
+
+ +
diff --git a/deployments/stitch-frontend/src/auth/AuthGate.jsx b/deployments/stitch-frontend/src/auth/AuthGate.jsx new file mode 100644 index 0000000..19cefd0 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.jsx @@ -0,0 +1,30 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import LoginPage from "../components/LoginPage"; + +export default function AuthGate({ children }) { + const { isLoading, isAuthenticated, error } = useAuth0(); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

+ Authentication error: {error.message} +

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return children; +} diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx new file mode 100644 index 0000000..7b39d3f --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import AuthGate from "./AuthGate"; + +describe("AuthGate", () => { + it("shows loading indicator while auth is loading", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isLoading: true, + isAuthenticated: false, + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + }); + + it("shows error message when auth fails", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isAuthenticated: false, + error: new Error("Something went wrong"), + }); + + render( + +
App Content
+
, + ); + + expect( + screen.getByText("Authentication error: Something went wrong"), + ).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + }); + + it("renders LoginPage when unauthenticated", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isAuthenticated: false, + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("Stitch")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /log in to continue/i }), + ).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + }); +}); diff --git a/deployments/stitch-frontend/src/auth/api.js b/deployments/stitch-frontend/src/auth/api.js new file mode 100644 index 0000000..80ba100 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/api.js @@ -0,0 +1,21 @@ +import config from "../config/env"; + +/** + * Returns a `fetch`-compatible function that automatically attaches a Bearer + * token to every request. The token is acquired on each call via Auth0's + * `getAccessTokenSilently`, so callers never handle tokens directly. + * + * @param {Function} getAccessTokenSilently - Auth0 SDK method for obtaining + * an access token without user interaction. + * @returns {Function} An async `(url, options?) => Response` fetcher. + */ +export function createAuthenticatedFetcher(getAccessTokenSilently) { + return async (url, options = {}) => { + const token = await getAccessTokenSilently({ + authorizationParams: { audience: config.auth0.audience }, + }); + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${token}`); + return fetch(url, { ...options, headers }); + }; +} diff --git a/deployments/stitch-frontend/src/auth/api.test.js b/deployments/stitch-frontend/src/auth/api.test.js new file mode 100644 index 0000000..3cbf848 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/api.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createAuthenticatedFetcher } from "./api"; + +describe("createAuthenticatedFetcher", () => { + let getAccessTokenSilently; + let fetcher; + + beforeEach(() => { + getAccessTokenSilently = vi.fn().mockResolvedValue("test-token"); + fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }))), + ); + }); + + it("attaches Bearer token to request", async () => { + await fetcher("http://api.test/data"); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("passes audience to getAccessTokenSilently", async () => { + await fetcher("http://api.test/data"); + + expect(getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { audience: "https://test-api" }, + }); + }); + + it("preserves caller headers", async () => { + await fetcher("http://api.test/data", { + headers: { "Content-Type": "application/json" }, + }); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Content-Type")).toBe("application/json"); + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("overrides caller Authorization with token", async () => { + await fetcher("http://api.test/data", { + headers: { Authorization: "Bearer old-token" }, + }); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("propagates token acquisition errors", async () => { + getAccessTokenSilently.mockRejectedValue(new Error("token error")); + fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + + await expect(fetcher("http://api.test/data")).rejects.toThrow( + "token error", + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("propagates fetch errors", async () => { + fetch.mockRejectedValue(new Error("network error")); + + await expect(fetcher("http://api.test/data")).rejects.toThrow( + "network error", + ); + }); +}); diff --git a/deployments/stitch-frontend/src/components/LoginPage.jsx b/deployments/stitch-frontend/src/components/LoginPage.jsx new file mode 100644 index 0000000..74bfa82 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LoginPage.jsx @@ -0,0 +1,22 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import Button from "./Button"; + +export default function LoginPage() { + const { loginWithRedirect } = useAuth0(); + + return ( +
+
+

Stitch

+

+ Oil & Gas Asset Data Platform +

+

+ Integrate diverse datasets, apply AI-driven enrichment with human + review, and deliver curated, trustworthy data. +

+ +
+
+ ); +} diff --git a/deployments/stitch-frontend/src/components/LoginPage.test.jsx b/deployments/stitch-frontend/src/components/LoginPage.test.jsx new file mode 100644 index 0000000..931e44a --- /dev/null +++ b/deployments/stitch-frontend/src/components/LoginPage.test.jsx @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import LoginPage from "./LoginPage"; + +describe("LoginPage", () => { + it("renders app name and description", () => { + render(); + + expect(screen.getByText("Stitch")).toBeInTheDocument(); + expect( + screen.getByText("Oil & Gas Asset Data Platform"), + ).toBeInTheDocument(); + }); + + it("calls loginWithRedirect on button click", async () => { + const loginWithRedirect = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + loginWithRedirect, + }); + + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole("button", { name: /log in to continue/i }), + ); + + expect(loginWithRedirect).toHaveBeenCalled(); + }); +}); diff --git a/deployments/stitch-frontend/src/components/LogoutButton.jsx b/deployments/stitch-frontend/src/components/LogoutButton.jsx new file mode 100644 index 0000000..4942842 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LogoutButton.jsx @@ -0,0 +1,13 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import Button from "./Button"; + +export const LogoutButton = () => { + const { isAuthenticated, logout: authLogout } = useAuth0(); + + const logout = () => + authLogout({ + openUrl: () => window.location.assign(window.location.origin), + }); + + return isAuthenticated && ; +}; diff --git a/deployments/stitch-frontend/src/components/LogoutButton.test.jsx b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx new file mode 100644 index 0000000..197ce5a --- /dev/null +++ b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import { LogoutButton } from "./LogoutButton"; + +describe("LogoutButton", () => { + it("renders when authenticated", () => { + render(); + expect( + screen.getByRole("button", { name: /log out/i }), + ).toBeInTheDocument(); + }); + + it("does not render when not authenticated", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isAuthenticated: false, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("calls logout with returnTo on click", async () => { + const logout = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, logout }); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /log out/i })); + + expect(logout).toHaveBeenCalledWith({ + openUrl: expect.any(Function), + }); + }); +}); diff --git a/deployments/stitch-frontend/src/components/ResourceView.jsx b/deployments/stitch-frontend/src/components/ResourceView.jsx index 65ba525..b393a79 100644 --- a/deployments/stitch-frontend/src/components/ResourceView.jsx +++ b/deployments/stitch-frontend/src/components/ResourceView.jsx @@ -6,6 +6,7 @@ import ClearCacheButton from "./ClearCacheButton"; import JsonView from "./JsonView"; import Input from "./Input"; import { resourceKeys } from "../queries/resources"; +import config from "../config/env"; export default function ResourceView({ className, endpoint }) { const queryClient = useQueryClient(); @@ -29,7 +30,7 @@ export default function ResourceView({ className, endpoint }) {
- {import.meta.env.VITE_API_URL} + {config.apiBaseUrl} {endpoint}
diff --git a/deployments/stitch-frontend/src/config/env.js b/deployments/stitch-frontend/src/config/env.js new file mode 100644 index 0000000..6adef6c --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.js @@ -0,0 +1,30 @@ +const REQUIRED_KEYS = [ + "VITE_AUTH0_DOMAIN", + "VITE_AUTH0_CLIENT_ID", + "VITE_AUTH0_AUDIENCE", +]; + +function loadConfig() { + const missing = REQUIRED_KEYS.filter((key) => !import.meta.env[key]); + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(", ")}. ` + + `Check your .env file or deployment config.`, + ); + } + + return Object.freeze({ + auth0: Object.freeze({ + domain: import.meta.env.VITE_AUTH0_DOMAIN, + clientId: import.meta.env.VITE_AUTH0_CLIENT_ID, + audience: import.meta.env.VITE_AUTH0_AUDIENCE, + }), + apiBaseUrl: import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1", + }); +} + +// Optional named export for `import { config } from "./config/env.js"` +export const config = loadConfig(); + +export default config; diff --git a/deployments/stitch-frontend/src/config/env.test.js b/deployments/stitch-frontend/src/config/env.test.js new file mode 100644 index 0000000..3e45544 --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +describe("config/env", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns correct config when all vars are present", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com"); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id"); + vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api"); + vi.stubEnv("VITE_API_URL", "http://localhost:9000/api/v1"); + + const { default: config } = await import("./env.js"); + + expect(config.auth0.domain).toBe("my.auth0.com"); + expect(config.auth0.clientId).toBe("my-client-id"); + expect(config.auth0.audience).toBe("https://my-api"); + expect(config.apiBaseUrl).toBe("http://localhost:9000/api/v1"); + }); + + it("throws when required vars are missing", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", ""); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", ""); + vi.stubEnv("VITE_AUTH0_AUDIENCE", ""); + + await expect(() => import("./env.js")).rejects.toThrow("VITE_AUTH0_DOMAIN"); + }); + + it("uses default API URL when VITE_API_URL is unset", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com"); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id"); + vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api"); + vi.stubEnv("VITE_API_URL", ""); + + const { default: config } = await import("./env.js"); + + expect(config.apiBaseUrl).toBe("http://localhost:8000/api/v1"); + }); +}); diff --git a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js new file mode 100644 index 0000000..32d0398 --- /dev/null +++ b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuth0 } from "@auth0/auth0-react"; +import { createAuthenticatedFetcher } from "../auth/api"; + +/** + * Wraps `useQuery` so that every request carries a valid Auth0 bearer token. + * + * Callers provide a `queryFn(fetcher)` instead of a plain `queryFn()`. The + * hook builds an authenticated fetcher (via `createAuthenticatedFetcher`) and + * passes it into `queryFn`, keeping token acquisition out of individual query + * functions. + * + * @param {object} queryOptions - Standard TanStack Query options, except + * `queryFn` receives an authenticated `fetcher` as its first argument. + */ +export function useAuthenticatedQuery(queryOptions) { + const { getAccessTokenSilently } = useAuth0(); + const fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + const { queryFn, ...rest } = queryOptions; + return useQuery({ ...rest, queryFn: () => queryFn(fetcher) }); +} diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js index 5957e20..dad38b3 100644 --- a/deployments/stitch-frontend/src/hooks/useResources.js +++ b/deployments/stitch-frontend/src/hooks/useResources.js @@ -1,10 +1,10 @@ -import { useQuery } from "@tanstack/react-query"; +import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; import { resourceQueries } from "../queries/resources"; export function useResources() { - return useQuery(resourceQueries.list()); + return useAuthenticatedQuery(resourceQueries.list()); } export function useResource(id) { - return useQuery(resourceQueries.detail(id)); + return useAuthenticatedQuery(resourceQueries.detail(id)); } diff --git a/deployments/stitch-frontend/src/main.jsx b/deployments/stitch-frontend/src/main.jsx index d489c37..e4221ad 100644 --- a/deployments/stitch-frontend/src/main.jsx +++ b/deployments/stitch-frontend/src/main.jsx @@ -1,8 +1,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Auth0Provider } from "@auth0/auth0-react"; import "./index.css"; import App from "./App.jsx"; +import AuthGate from "./auth/AuthGate"; +import config from "./config/env"; // Set global defaults for QueryClient const queryClient = new QueryClient({ @@ -16,8 +19,20 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")).render( - - - + + + + + + + , ); diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index d585eb5..91f54d9 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -1,9 +1,8 @@ -const API_BASE_URL = - import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1"; +import config from "../config/env"; -export async function getResources() { - const url = `${API_BASE_URL}/resources/`; - const response = await fetch(url); +export async function getResources(fetcher) { + const url = `${config.apiBaseUrl}/resources/`; + const response = await fetcher(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -11,9 +10,9 @@ export async function getResources() { return data; } -export async function getResource(id) { - const url = `${API_BASE_URL}/resources/${id}`; - const response = await fetch(url); +export async function getResource(id, fetcher) { + const url = `${config.apiBaseUrl}/resources/${id}`; + const response = await fetcher(url); if (!response.ok) { const error = new Error(`HTTP error! status: ${response.status}`); error.status = response.status; diff --git a/deployments/stitch-frontend/src/queries/api.test.js b/deployments/stitch-frontend/src/queries/api.test.js index 54425a3..3645043 100644 --- a/deployments/stitch-frontend/src/queries/api.test.js +++ b/deployments/stitch-frontend/src/queries/api.test.js @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getResources, getResource } from "./api"; describe("API Functions", () => { + let mockFetcher; + beforeEach(() => { - global.fetch = vi.fn(); + mockFetcher = vi.fn(); }); describe("getResources", () => { @@ -13,33 +15,35 @@ describe("API Functions", () => { { id: 2, name: "Resource 2", type: "test" }, ]; - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResources, }); - const result = await getResources(); + const result = await getResources(mockFetcher); - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetcher).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/", ); expect(result).toEqual(mockResources); }); it("throws error when response is not ok", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 500, }); - await expect(getResources()).rejects.toThrow("HTTP error! status: 500"); + await expect(getResources(mockFetcher)).rejects.toThrow( + "HTTP error! status: 500", + ); }); it("throws error on network failure", async () => { - global.fetch.mockRejectedValueOnce(new Error("Network error")); + mockFetcher.mockRejectedValueOnce(new Error("Network error")); - await expect(getResources()).rejects.toThrow("Network error"); + await expect(getResources(mockFetcher)).rejects.toThrow("Network error"); }); }); @@ -47,28 +51,28 @@ describe("API Functions", () => { it("fetches and returns a single resource successfully", async () => { const mockResource = { id: 42, name: "Test Resource", type: "example" }; - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResource, }); - const result = await getResource(42); + const result = await getResource(42, mockFetcher); - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetcher).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/42", ); expect(result).toEqual(mockResource); }); it("throws error with status when response is not ok", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 404, }); try { - await getResource(999); + await getResource(999, mockFetcher); expect.fail("Should have thrown an error"); } catch (error) { expect(error.message).toBe("HTTP error! status: 404"); @@ -77,33 +81,35 @@ describe("API Functions", () => { }); it("includes status code in error object for 404", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 404, }); - await expect(getResource(123)).rejects.toMatchObject({ + await expect(getResource(123, mockFetcher)).rejects.toMatchObject({ message: "HTTP error! status: 404", status: 404, }); }); it("includes status code in error object for 500", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 500, }); - await expect(getResource(1)).rejects.toMatchObject({ + await expect(getResource(1, mockFetcher)).rejects.toMatchObject({ message: "HTTP error! status: 500", status: 500, }); }); it("throws error on network failure", async () => { - global.fetch.mockRejectedValueOnce(new Error("Failed to fetch")); + mockFetcher.mockRejectedValueOnce(new Error("Failed to fetch")); - await expect(getResource(1)).rejects.toThrow("Failed to fetch"); + await expect(getResource(1, mockFetcher)).rejects.toThrow( + "Failed to fetch", + ); }); }); }); diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js index a2b497d..3ff7413 100644 --- a/deployments/stitch-frontend/src/queries/resources.js +++ b/deployments/stitch-frontend/src/queries/resources.js @@ -13,13 +13,13 @@ export const resourceKeys = { export const resourceQueries = { list: () => ({ queryKey: resourceKeys.lists(), - queryFn: getResources, + queryFn: (fetcher) => getResources(fetcher), enabled: false, }), detail: (id) => ({ queryKey: resourceKeys.detail(id), - queryFn: () => getResource(id), + queryFn: (fetcher) => getResource(id, fetcher), enabled: false, }), }; diff --git a/deployments/stitch-frontend/src/test/setup.js b/deployments/stitch-frontend/src/test/setup.js index 971941d..9478604 100644 --- a/deployments/stitch-frontend/src/test/setup.js +++ b/deployments/stitch-frontend/src/test/setup.js @@ -1,4 +1,4 @@ -import { expect, afterEach } from "vitest"; +import { expect, afterEach, vi } from "vitest"; import { cleanup } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; @@ -7,3 +7,16 @@ expect.extend(matchers); afterEach(() => { cleanup(); }); + +vi.mock("@auth0/auth0-react", () => ({ + useAuth0: vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + error: null, + user: { sub: "test-user-id", email: "test@example.com" }, + getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"), + loginWithRedirect: vi.fn(), + logout: vi.fn(), + }), + Auth0Provider: ({ children }) => children, +})); diff --git a/deployments/stitch-frontend/src/test/utils.jsx b/deployments/stitch-frontend/src/test/utils.jsx index cc13656..cc69045 100644 --- a/deployments/stitch-frontend/src/test/utils.jsx +++ b/deployments/stitch-frontend/src/test/utils.jsx @@ -1,6 +1,23 @@ +import { vi } from "vitest"; import { render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +/** + * Default return value for the mocked `useAuth0` hook (mirrors setup.js). + * Spread and override in tests that need a different auth state: + * + * vi.mocked(useAuth0).mockReturnValue({ ...auth0Defaults, isAuthenticated: false }); + */ +export const auth0TestDefaults = { + isAuthenticated: true, + isLoading: false, + error: null, + user: { sub: "test-user-id", email: "test@example.com" }, + getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"), + loginWithRedirect: vi.fn(), + logout: vi.fn(), +}; + export function renderWithQueryClient(ui, options = {}) { const queryClient = new QueryClient({ defaultOptions: { diff --git a/deployments/stitch-frontend/vitest.config.js b/deployments/stitch-frontend/vitest.config.js index 908aa37..606ff9b 100644 --- a/deployments/stitch-frontend/vitest.config.js +++ b/deployments/stitch-frontend/vitest.config.js @@ -8,5 +8,11 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./src/test/setup.js", css: true, + env: { + VITE_AUTH0_DOMAIN: "test.auth0.com", + VITE_AUTH0_CLIENT_ID: "test-client-id", + VITE_AUTH0_AUDIENCE: "https://test-api", + VITE_API_URL: "http://localhost:8000/api/v1", + }, }, }); diff --git a/dev/auth-demo.sh b/dev/auth-demo.sh new file mode 100755 index 0000000..fdd0e5e --- /dev/null +++ b/dev/auth-demo.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# +# Interactive auth testing demo. +# Walks through each auth scenario against the local API + localauth0. +# +# Prerequisites: +# 1. .env configured with AUTH_DISABLED=false and localauth0 settings +# 2. Stack running: docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up +# +# Usage: +# bash dev/auth-demo.sh + +set -euo pipefail + +API=localhost:8000/api/v1 +OIDC=localhost:3100 + +BOLD='\033[1m' +DIM='\033[2m' +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +RESET='\033[0m' + +# ── helpers ────────────────────────────────────────────────────────── + +step_number=0 + +wait_for_enter() { + echo "" + read -rp "$(echo -e "${DIM}Press Enter to run...${RESET}")" +} + +show_step() { + step_number=$((step_number + 1)) + local title=$1 + local description=$2 + local expect=$3 + + echo "" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" + echo -e "${BOLD} Scenario ${step_number}: ${title}${RESET}" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" + echo -e " ${description}" + echo -e " ${CYAN}Expected: ${expect}${RESET}" +} + +show_cmd() { + echo -e "\n ${DIM}\$${RESET} $1" +} + +run_curl() { + local label=$1 + shift + echo "" + # Run curl, capture status code on last line + local response + response=$(curl -s -w "\n%{http_code}" "$@") + local http_code + http_code=$(echo "$response" | tail -1) + local body + body=$(echo "$response" | sed '$d') + + if [ -n "$body" ]; then + echo -e " ${DIM}Body:${RESET}" + # Pretty-print JSON if jq is available, otherwise raw + if command -v jq &>/dev/null; then + echo "$body" | jq . 2>/dev/null | sed 's/^/ /' || echo " $body" + else + echo " $body" + fi + fi + echo -e " ${BOLD}HTTP ${http_code}${RESET}" +} + +# ── preflight checks ──────────────────────────────────────────────── + +echo -e "${BOLD}Auth Testing Demo${RESET}" +echo -e "${DIM}Testing API at ${API}, OIDC at ${OIDC}${RESET}" +echo "" + +echo -n "Checking API... " +if curl -sf -o /dev/null "${API}/health" 2>/dev/null; then + echo -e "${GREEN}OK${RESET}" +else + echo -e "${RED}FAILED${RESET}" + echo " API is not reachable at ${API}. Is the stack running?" + exit 1 +fi + +echo -n "Checking localauth0... " +if curl -sf -o /dev/null "${OIDC}/.well-known/openid-configuration" 2>/dev/null; then + echo -e "${GREEN}OK${RESET}" +else + echo -e "${RED}FAILED${RESET}" + echo " localauth0 is not reachable at ${OIDC}." + echo " Start with: docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up" + exit 1 +fi + +# ── scenario 1: health endpoint (no auth required) ────────────────── + +show_step \ + "Health endpoint (no auth)" \ + "The /health endpoint is always open — no token required." \ + "200" +show_cmd "curl ${API}/health" +wait_for_enter +run_curl "health" "${API}/health" + +# ── scenario 2: no authorization header ───────────────────────────── + +show_step \ + "No Authorization header" \ + "A request with no token at all. The API checks for the Authorization\n header and rejects the request before any JWT parsing happens." \ + "401" +show_cmd "curl ${API}/resources/" +wait_for_enter +run_curl "no-auth" "${API}/resources/" + +# ── scenario 3: malformed header (wrong scheme) ───────────────────── + +show_step \ + "Malformed Authorization header" \ + "Using 'Basic' instead of 'Bearer'. The API parses the scheme and\n rejects anything that isn't 'Bearer '." \ + "401" +show_cmd "curl -H 'Authorization: Basic xyz' ${API}/resources/" +wait_for_enter +run_curl "basic-auth" "${API}/resources/" -H "Authorization: Basic xyz" + +# ── scenario 4: garbage token (wrong signing key) ─────────────────── + +show_step \ + "Garbage token (invalid JWT)" \ + "A string that isn't a valid JWT. The JWKS client can't find a matching\n key ID, so signature verification fails." \ + "401" +show_cmd "curl -H 'Authorization: Bearer not.a.real.jwt' ${API}/resources/" +wait_for_enter +run_curl "garbage" "${API}/resources/" -H "Authorization: Bearer not.a.real.jwt" + +# ── scenario 5: wrong audience ────────────────────────────────────── + +show_step \ + "Valid JWT, wrong audience" \ + "A properly signed token from localauth0, but issued for 'wrong-audience'\n instead of 'stitch-api-local'. The API validates the 'aud' claim and rejects it." \ + "401" + +show_cmd "curl -s -X POST ${OIDC}/oauth/token -H 'Content-Type: application/json' \\" +echo -e " ${DIM}-d '{\"audience\":\"wrong-audience\", ...}'${RESET}" +wait_for_enter + +WRONG_TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token' 2>/dev/null) || true + +if [ -z "$WRONG_TOKEN" ] || [ "$WRONG_TOKEN" = "null" ]; then + echo -e " ${RED}Failed to get token from localauth0${RESET}" + exit 1 +fi +echo -e " ${GREEN}Got token${RESET} ${DIM}(${#WRONG_TOKEN} chars)${RESET}" + +show_cmd "curl -H 'Authorization: Bearer \$WRONG_TOKEN' ${API}/resources/" +wait_for_enter +run_curl "wrong-aud" "${API}/resources/" -H "Authorization: Bearer ${WRONG_TOKEN}" + +# ── scenario 6: valid token, first request (JIT provisioning) ─────── + +show_step \ + "Valid token — first request (JIT user creation)" \ + "A properly signed token with the correct audience. On the first\n authenticated request, the API creates a new user row in the database\n from the token's sub/name/email claims." \ + "200 + user JIT-created in DB" + +show_cmd "curl -s -X POST ${OIDC}/oauth/token -H 'Content-Type: application/json' \\" +echo -e " ${DIM}-d '{\"audience\":\"stitch-api-local\", ...}'${RESET}" +wait_for_enter + +TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token' 2>/dev/null) || true + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo -e " ${RED}Failed to get token from localauth0${RESET}" + exit 1 +fi +echo -e " ${GREEN}Got token${RESET} ${DIM}(${#TOKEN} chars)${RESET}" + +show_cmd "curl -H 'Authorization: Bearer \$TOKEN' ${API}/resources/" +wait_for_enter +run_curl "valid-first" "${API}/resources/" -H "Authorization: Bearer ${TOKEN}" + +# ── scenario 7: valid token, repeat request ───────────────────────── + +show_step \ + "Valid token — repeat request (user already exists)" \ + "Same token again. The API finds the existing user by 'sub' and updates\n name/email from the token claims. No new row is created." \ + "200 + user info updated" +show_cmd "curl -H 'Authorization: Bearer \$TOKEN' ${API}/resources/" +wait_for_enter +run_curl "valid-repeat" "${API}/resources/" -H "Authorization: Bearer ${TOKEN}" + +# ── done ───────────────────────────────────────────────────────────── + +echo "" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo -e "${GREEN}${BOLD} All scenarios complete.${RESET}" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo "" +echo -e " Verify JIT user in Adminer: ${CYAN}http://localhost:8081${RESET}" +echo -e " Try Swagger UI: ${CYAN}http://localhost:8000/docs${RESET}" +echo "" diff --git a/dev/localauth0-proxy.conf b/dev/localauth0-proxy.conf new file mode 100644 index 0000000..f604415 --- /dev/null +++ b/dev/localauth0-proxy.conf @@ -0,0 +1,71 @@ +# CORS + nonce-forwarding proxy for localauth0 (requires OpenResty). +# +# localauth0 doesn't propagate the OIDC nonce from /authorize into the +# ID token. It *will* include one if the /oauth/token request body has a +# "nonce" field. The Auth0 SPA SDK only sends nonce on the authorize +# redirect (per spec), so we capture it there and inject it into the +# token exchange. + +lua_shared_dict nonces 1m; + +server { + listen 80; + server_name _; + + # --- /authorize: capture nonce (browser navigation, no CORS needed) --- + location = /authorize { + access_by_lua_block { + local raw = ngx.var.arg_nonce + if raw then + ngx.shared.nonces:set("latest", ngx.unescape_uri(raw), 300) + end + } + + proxy_pass http://localauth0:3000; + } + + # --- /oauth/token: inject stored nonce into request body --------------- + location = /oauth/token { + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client'; + add_header 'Access-Control-Max-Age' 86400; + return 204; + } + + access_by_lua_block { + ngx.req.read_body() + local body = ngx.req.get_body_data() + local nonce = ngx.shared.nonces:get("latest") + if nonce and body then + body = body .. "&nonce=" .. ngx.escape_uri(nonce) + ngx.req.set_body_data(body) + ngx.req.set_header("Content-Length", tostring(#body)) + end + } + + proxy_pass http://localauth0:3000; + + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client' always; + } + + # --- everything else: plain proxy + CORS ------------------------------- + location / { + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client'; + add_header 'Access-Control-Max-Age' 86400; + return 204; + } + + proxy_pass http://localauth0:3000; + + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client' always; + } +} diff --git a/dev/localauth0.toml b/dev/localauth0.toml new file mode 100644 index 0000000..454feaf --- /dev/null +++ b/dev/localauth0.toml @@ -0,0 +1,27 @@ +issuer = "http://localhost:3100/" + +[user_info] +subject = "mock|dev-user-1" +name = "Dev User" +email = "dev@example.com" +email_verified = true + +[[audience]] +name = "stitch-api-local" +permissions = [] + +[[audience]] +name = "wrong-audience" +permissions = [] + +# Inject user-profile claims into the access token. +# This mirrors a real Auth0 "Login / Post Login" Action that copies +# email and name into the access_token for API consumption. +[access_token] +custom_claims = [ + { name = "email", value = { String = "dev@example.com" } }, + { name = "name", value = { String = "Dev User" } }, +] + +[http] +port = 3000 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 1808182..241564a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -19,3 +19,25 @@ services: - /app/packages - --reload-exclude - "*/tests/*" + localauth0: + profiles: [auth-test] + image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 + healthcheck: + test: ["CMD", "/localauth0", "healthcheck"] + start_period: 10s + start_interval: 1s + interval: 30s + environment: + LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml + volumes: + - ./dev/localauth0.toml:/etc/localauth0.toml:ro + auth0-proxy: + profiles: [auth-test] + image: openresty/openresty:alpine + ports: + - "3100:80" + volumes: + - ./dev/localauth0-proxy.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + localauth0: + condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 0175c1f..f076d72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,11 @@ services: build: context: deployments/stitch-frontend dockerfile: Dockerfile + args: + VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-} + VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-} + VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-} + VITE_API_URL: ${VITE_API_URL:-http://localhost:8000/api/v1} ports: - "3000:80" diff --git a/docs/auth-testing.md b/docs/auth-testing.md new file mode 100644 index 0000000..21986df --- /dev/null +++ b/docs/auth-testing.md @@ -0,0 +1,191 @@ +# Local Auth Testing Guide + +This guide covers how to test JWT authentication locally using [localauth0](https://github.com/primait/localauth0), a lightweight mock OIDC server. + +## How Auth Works in Production + +When `AUTH_DISABLED=false`, every request (except `/health`) goes through a JWT validation pipeline that mirrors a real Auth0 deployment: + +1. **Header parsing** — extract the `Bearer ` from the `Authorization` header +2. **JWKS fetch** — retrieve the signing key from the OIDC provider's `/.well-known/jwks.json` endpoint (cached for 600s) +3. **Signature verification** — verify the token was signed with the provider's private key (RS256) +4. **Claims validation** — the following claims are required and checked: + | Claim | Check | + |-------|-------| + | `exp` | Token has not expired (with 30s clock skew tolerance) | + | `nbf` | Token is not used before its "not before" time | + | `iss` | Issuer matches `AUTH_ISSUER` | + | `aud` | Audience matches `AUTH_AUDIENCE` | + | `sub` | Subject is present (unique user identifier) | +5. **User provisioning** — the `sub` claim is looked up in the `users` table. On first login, a user row is JIT-created; on subsequent logins, `name` and `email` are updated from the token claims. + +Any failure at steps 1-4 returns a **401** with `WWW-Authenticate: Bearer`. + +**Production guardrail:** `AUTH_DISABLED=true` is blocked at startup when `ENVIRONMENT=prod`. + +## Default Mode (auth disabled) + +By default, `AUTH_DISABLED=true` in `.env`. All API requests are accepted without tokens, and a hardcoded dev user (`sub="dev|local-placeholder"`) is injected. This is the normal local development experience. + +## Enabling Auth Testing + +### 1. Configure environment + +Update `.env` with the auth-test settings: + +``` +AUTH_DISABLED=false +AUTH_ISSUER=http://localhost:3100/ +AUTH_AUDIENCE=stitch-api-local +AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json +``` + +### 2. Start the stack + +```bash +docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build +``` + +This starts the normal stack (db, api, frontend) plus `localauth0` (Docker-internal on port 3000) and `auth0-proxy` (OpenResty on host port 3100). See [Local Auth Proxy](local-auth-proxy.md) for details on the proxy architecture. + +### 3. Verify localauth0 is running + +```bash +curl -s localhost:3100/.well-known/openid-configuration | jq . +``` + +## Getting Tokens + +Tokens from localauth0 are valid for **24 hours** (`expires_in: 86400`). Expired-token validation is covered by unit tests in `packages/stitch-auth/tests/test_validator_unit.py`. + +### Valid token (correct audience) + +```bash +TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') + +echo $TOKEN +``` + +### Token with wrong audience + +```bash +WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +``` + +## Test Scenarios + +| # | Scenario | Command | Expected | +| --- | --------------------------------- | --------------------------------------------------------------------------------- | ---------------------- | +| 1 | No Authorization header | `curl localhost:8000/api/v1/resources/` | 401 | +| 2 | Malformed header | `curl -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/` | 401 | +| 3 | Garbage token (wrong signing key) | `curl -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/` | 401 | +| 4 | Wrong audience | `curl -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/` | 401 | +| 5 | Valid token, first request | `curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/` | 200, user JIT-created | +| 6 | Valid token, repeat request | Same as #5 | 200, user info updated | +| 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | + +**Not testable with localauth0:** wrong-issuer rejection (localauth0's issuer is fixed). This is validated in production and covered by unit tests (`test_validator_unit.py::test_wrong_issuer_raises`). + +### Interactive demo script + +Run the scenarios interactively with step-by-step confirmation: + +```bash +bash dev/auth-demo.sh +``` + +### Running the scenarios manually + +```bash +# 1. No token +curl -s -o /dev/null -w "%{http_code}" localhost:8000/api/v1/resources/ +# → 401 + +# 2. Malformed header +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/ +# → 401 + +# 3. Garbage token +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/ +# → 401 + +# 4. Wrong audience +WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/ +# → 401 + +# 5. Valid token (first request — JIT user creation) +TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ +# → 200 + +# 6. Same token again (user already exists) +curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ +# → 200 + +# 7. Health endpoint (always open) +curl -s -o /dev/null -w "%{http_code}" localhost:8000/api/v1/health +# → 200 +``` + +## Verifying JIT User Provisioning + +After a successful authenticated request, verify the user was created in the database via Adminer: + +1. Open http://localhost:8081 +2. Connect to `stitch` database (user: `postgres`, password: `postgres`) +3. Browse the `users` table +4. You should see a row with `sub = "mock|dev-user-1"` + +## Using Swagger UI + +1. Open http://localhost:8000/docs +2. Click the "Authorize" button (lock icon) +3. Enter a Bearer token obtained from localauth0 +4. Click "Authorize" +5. All subsequent "Try it out" requests will include the token + +## Frontend Auth Flow + +The frontend at http://localhost:3000 uses the Auth0 SPA SDK to authenticate via the proxy at `localhost:3100`. After login, it attaches Bearer tokens to all API requests. The API's CORS middleware allows the `Authorization` header from `FRONTEND_ORIGIN_URL`. + +To test the full browser flow: + +1. Open http://localhost:3000 — you'll see the login page +2. Click "Log in to continue" — redirects to localauth0 via the proxy +3. Click "Login" on localauth0's page — redirects back to the app +4. The app fetches resources with the token attached + +## localauth0 Configuration + +The mock server is configured via `dev/localauth0.toml`: + +- **Issuer**: `http://localhost:3100/` (matches `AUTH_ISSUER`; the proxy's address, not localauth0's internal port) +- **User**: `sub=mock|dev-user-1`, name "Dev User", email `dev@example.com` +- **Audiences**: `stitch-api-local` (valid) and `wrong-audience` (for testing rejection) +- **Port**: 3000 inside Docker, exposed via `auth0-proxy` on host port 3100 + +For proxy-specific details and troubleshooting, see [Local Auth Proxy](local-auth-proxy.md). + +## Configuring Real Auth0 + +For staging or production, replace the environment variables with your Auth0 tenant values: + +``` +AUTH_DISABLED=false +AUTH_ISSUER=https://your-tenant.auth0.com/ +AUTH_AUDIENCE=your-api-audience +AUTH_JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json +``` diff --git a/docs/local-auth-proxy.md b/docs/local-auth-proxy.md new file mode 100644 index 0000000..07951f4 --- /dev/null +++ b/docs/local-auth-proxy.md @@ -0,0 +1,86 @@ +# Local Auth Proxy (OpenResty) + +The auth-test Docker profile includes an [OpenResty](https://openresty.org/) reverse proxy (`auth0-proxy`) that sits between the browser and [localauth0](https://github.com/primait/localauth0). This doc explains why it exists and how to troubleshoot it. + +## Why a proxy? + +The Auth0 SPA SDK makes cross-origin XHR requests (token exchange, userinfo) from the frontend origin (`http://localhost:3000`) to the OIDC provider. localauth0 doesn't set CORS headers, so the browser blocks these requests. + +In production, Auth0 lives on a separate origin from the frontend and handles CORS itself. The proxy mirrors this architecture locally: the frontend talks to `localhost:3100` (the proxy), which forwards to `localauth0:3000` inside the Docker network. + +``` +Browser + ├─ localhost:3000 ──► Frontend (nginx, serves SPA) + ├─ localhost:3100 ──► Auth0 Proxy (OpenResty) ──► localauth0:3000 (Docker internal) + └─ localhost:8000 ──► API (FastAPI) + └─ JWKS validation via http://localauth0:3000/.well-known/jwks.json +``` + +## Why OpenResty instead of plain nginx? + +Plain nginx handles CORS fine, but localauth0 has a second limitation: it doesn't propagate the OIDC **nonce** from `/authorize` into the ID token. + +The OIDC spec says the nonce is sent in the `/authorize` redirect and returned in the ID token. localauth0 ignores it there — but *will* include a nonce if the `/oauth/token` request body contains a `nonce` field. The Auth0 SPA SDK (correctly) only sends nonce on `/authorize`, not in the token request. + +The proxy uses Lua (`access_by_lua_block`) to bridge this gap: + +1. On `GET /authorize?nonce=xxx` — captures the nonce into a shared memory dict +2. On `POST /oauth/token` — appends `&nonce=xxx` to the request body before forwarding + +This requires OpenResty (nginx + LuaJIT). See `dev/localauth0-proxy.conf` for the full config. + +## localauth0 limitations & workarounds + +| Limitation | Workaround | Details | +|---|---|---| +| No CORS headers | Proxy adds `Access-Control-Allow-*` | Standard nginx `add_header` directives | +| No nonce propagation | Lua captures from `/authorize`, injects into `/oauth/token` | `lua_shared_dict` with 5-min TTL | +| Hardcoded `client_id` | Use literal string `"client_id"` | localauth0 [hardcodes](https://github.com/primait/localauth0/blob/master/src/lib.rs) `CLIENT_ID_VALUE = "client_id"` — not configurable via TOML | +| No `/v2/logout` endpoint | `LogoutButton` uses SDK's `openUrl` option | Navigates directly to `window.location.origin` after clearing local session | +| ServiceWorker on `:3100` | Clear manually if it causes issues | localauth0 registers a SW that can cache responses on the proxy's origin | + +## Starting the stack + +```bash +docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build +``` + +The `auth0-proxy` service waits for localauth0's healthcheck before starting. The healthcheck is configured with `start_period: 10s` and `start_interval: 1s`, so the proxy should be ready within ~15 seconds of localauth0 starting. + +## Troubleshooting + +### CORS errors on `/oauth/token` + +The browser blocks the token exchange response. Check: + +1. Is `auth0-proxy` running? `curl -s http://localhost:3100/.well-known/openid-configuration | head -1` +2. Open browser DevTools → Network tab → look at the failed request's response headers. You should see `Access-Control-Allow-Origin: http://localhost:3000`. +3. If headers are missing, the request may not be hitting the proxy (check the URL is `localhost:3100`, not `localhost:3000`). + +### "Nonce (nonce) claim must be a string present in the ID token" + +The proxy didn't inject the nonce into the token request. Check: + +1. Was `/authorize` routed through the proxy (`localhost:3100`)? The nonce is captured from this request. +2. Check proxy logs: `docker compose logs auth0-proxy`. Look for the `GET /authorize` and `POST /oauth/token` entries. +3. If the proxy was restarted between `/authorize` and `/oauth/token`, the nonce is lost (stored in memory). Retry the login. + +### 401 on token exchange (`"access_denied"`) + +localauth0 rejected the token request. The most common cause: + +- **Wrong `client_id`**: localauth0 expects the literal string `"client_id"`. Check `VITE_AUTH0_CLIENT_ID=client_id` in `.env`. Any other value (e.g., `local-test-client`) will fail. + +### Logout stuck at `localhost:3100/v2/logout` + +localauth0 doesn't implement Auth0's `/v2/logout` endpoint. The `LogoutButton` uses the SDK's `openUrl` option to bypass this, but if you see this URL: + +1. localauth0's **ServiceWorker** may be intercepting the navigation. Open DevTools → Application → Service Workers → Unregister any SW on `localhost:3100`. +2. Hard-refresh (`Ctrl+Shift+R`) or clear site data for `localhost:3100`. + +### Slow proxy startup + +The proxy depends on localauth0 being healthy. If localauth0 takes a long time: + +1. Check localauth0 logs: `docker compose logs localauth0` +2. The healthcheck runs `/localauth0 healthcheck` every 1s during the start period (10s). After that, it falls back to 30s intervals. diff --git a/env.example b/env.example deleted file mode 100644 index a51f749..0000000 --- a/env.example +++ /dev/null @@ -1,17 +0,0 @@ -LOG_LEVEL=info - -POSTGRES_DB=stitch -POSTGRES_HOST=db -POSTGRES_PORT=5432 - -STITCH_MIGRATOR_PASSWORD=CHANGE_ME_migrator123! -STITCH_APP_PASSWORD=CHANGE_ME_app123! - -STITCH_DB_SCHEMA_MODE="if-empty" -STITCH_DB_SEED_MODE="if-needed" -STITCH_DB_SEED_PROFILE="dev" - -FRONTEND_ORIGIN_URL=http://localhost:3000 - -# Auth (AUTH_DISABLED=true bypasses JWT validation for local dev) -AUTH_DISABLED=true diff --git a/packages/stitch-auth/pyproject.toml b/packages/stitch-auth/pyproject.toml index f0843fd..11b85dc 100644 --- a/packages/stitch-auth/pyproject.toml +++ b/packages/stitch-auth/pyproject.toml @@ -5,9 +5,9 @@ description = "Provider-agnostic OIDC JWT validation for Stitch" authors = [{ name = "Michael Barlow", email = "mbarlow@rmi.org" }] requires-python = ">=3.12" dependencies = [ - "pyjwt[crypto]>=2.9.0", - "pydantic>=2.0", - "pydantic-settings>=2.11.0", + "pyjwt[crypto]>=2.9.0", + "pydantic>=2.0", + "pydantic-settings>=2.11.0", ] [build-system] @@ -18,10 +18,7 @@ build-backend = "uv_build" module-name = "stitch.auth" [dependency-groups] -dev = [ - "pytest>=8.0", - "cryptography>=44.0.0", -] +dev = ["pytest>=8.0", "cryptography>=44.0.0"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/packages/stitch-core/pyproject.toml b/packages/stitch-core/pyproject.toml index d34ef7b..c9e4090 100644 --- a/packages/stitch-core/pyproject.toml +++ b/packages/stitch-core/pyproject.toml @@ -33,3 +33,9 @@ markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "ruff>=0.14.12", +] diff --git a/uv.lock b/uv.lock index ed186f8..33bedb7 100644 --- a/uv.lock +++ b/uv.lock @@ -1177,6 +1177,12 @@ dependencies = [ { name = "sqlalchemy" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "greenlet", specifier = ">=3.3.0" }, @@ -1185,6 +1191,12 @@ requires-dist = [ { name = "sqlalchemy", specifier = ">=2.0.44" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "ruff", specifier = ">=0.14.12" }, +] + [[package]] name = "typer" version = "0.21.1"