A reference implementation for teams building vanilla React (Vite) SPAs that authenticate users against Okta via OIDC, gate every route behind that session, and call a GraphQL API via Apollo Client using the user's Okta access token. Built with:
- Vite 5 + React 18 + TypeScript + Tailwind 3 + shadcn/ui
- react-oidc-context (built on
oidc-client-ts) for the OIDC client - Apollo Client 4 for the GraphQL client
- React Router 6 for route-level auth gating
- Vitest for tests
For the equivalent setup using Next.js + Auth.js + RSC, see the sibling repo nextjs-reference-implementation.
┌──────────────┐
│ Browser │
└──────┬───────┘
│
▼
┌──────────────────────────────────┐
│ <MatthewsAuthGraphqlProvider> │ in src/App.tsx
│ ├─ AuthProvider (Okta OIDC) │ react-oidc-context
│ └─ ApolloProvider │ authLink + refreshLink + httpLink
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ <RequireAuth> │ signinRedirect → Okta hosted login
└──────────────┬───────────────────┘
│
▼
useQuery / useMutation / useSuspenseQuery
│
▼
Bearer = user.access_token
│
▼
VITE_GRAPHQL_API_URL
All wired in: src/lib/matthews-auth-graphql/
import {
MatthewsAuthGraphqlProvider,
RequireAuth,
LoginCallback,
useAuth,
} from "@/lib/matthews-auth-graphql";| Export | Use |
|---|---|
MatthewsAuthGraphqlProvider |
Wrap once in App.tsx above your routes |
RequireAuth |
Route element that gates protected routes (auto-redirects to Okta's hosted sign-in) |
LoginCallback |
Route element for redirect_uri (typically /callback) |
useAuth |
Re-exported from react-oidc-context for session metadata |
App builders import useQuery / useMutation / useSuspenseQuery directly from @apollo/client/react.
Copy and paste this prompt to your LLM agent (Claude Code, Cursor, AmpCode, etc.):
Read the conventions for this codebase before adding any routes, components, or auth/GraphQL code. The provider in
src/lib/matthews-auth-graphql/already handles Okta OIDC and Apollo wiring — don't duplicate it.
Fetch the conventions and follow them:
curl -s https://raw.githubusercontent.com/MatthewsREIS/lovable-reference-implementation/refs/heads/main/AGENTS.md# Install dependencies
npm install
# Copy env template and fill in the Okta values
cp .env.example .env.local
# edit .env.local — see table below
# Start the dev server (port 3000 — must match VITE_OKTA_REDIRECT_URI)
npm run devGet the values from the Matthews portal — visit portal.matthews.com/console/builder/apps, choose your app, and copy the values from the configuration screen.
| Variable | Required | Purpose |
|---|---|---|
VITE_OKTA_ISSUER |
yes | Base Okta domain, e.g. https://your-org.okta.com — no /oauth2/default suffix. Uses the Org Authorization Server. |
VITE_OKTA_CLIENT_ID |
yes | Okta OIDC public client ID. |
VITE_GRAPHQL_API_URL |
yes | Browser-reachable GraphQL endpoint. |
VITE_OKTA_REDIRECT_URI |
no | Where Okta sends the user after consent. Defaults to ${origin}/api/auth/callback/okta — same path the nextjs sibling uses, so one Okta app config covers both repos. Override only if you need a different path. |
If any required variable is missing, the app renders a helpful "Configuration required" screen at startup instead of a blank page.
If you don't own the Okta tenant, ask whoever administers it for VITE_OKTA_ISSUER and VITE_OKTA_CLIENT_ID, and have them add http://localhost:3000/api/auth/callback/okta (and your deployed callback URL) to the app's Sign-in redirect URIs. The nextjs sibling uses the same callback path, so one registration covers both repos — but they share the same default port (3000), so don't run both dev servers simultaneously.
Visit http://localhost:3000:
<RequireAuth>finds no session and redirects to Okta's hosted sign-in page.- After consent you land on
/api/auth/callback/oktabriefly, then on/with the demo page. - Open DevTools → Network →
VITE_GRAPHQL_API_URLrequest: theAuthorization: Bearer …header carries the Okta access token.
- Initial sign-in.
react-oidc-contextruns the OIDC code-exchange viaoidc-client-tsand storesaccess_token+refresh_token+expires_atinlocalStorage. - Pre-emptive renewal. With
automaticSilentRenew: true,oidc-client-tsschedules a refresh shortly before expiry and rotates the access token transparently. - Reactive renewal on 401. If a request goes out with a just-expired token and the API returns 401, the Apollo
refreshLinkcallsauth.signinSilent(), updates the bearer header, and retries the operation once. A second 401 bubbles up.
oidc-client-ts internally serializes silent-renew calls, so concurrent refreshes can't fight each other (the equivalent of the nextjs inflightRefreshes map).
If a refresh ultimately fails — e.g. the Okta refresh token has been revoked — useAuth().error surfaces the failure. The Sign-out button (or any code that calls auth.removeUser()) clears the local session and the user can re-authenticate.
Sign-out is local-only by design: clicking Sign out clears this app's stored OIDC session and reloads /. The user's Okta session is untouched — they remain signed in to Okta and to every other Okta-backed application.
Caveat: with no intermediate "you're signed out" page, the reload re-runs <RequireAuth> which redirects to Okta. Because the SSO session is still alive, Okta silently re-issues a code and the user is signed back in immediately — the button looks like a no-op. This is the documented tradeoff of local-only logout without an intermediate landing page; matches common corporate-SSO patterns (Salesforce, Slack Enterprise, etc.) where "Sign out of App X" doesn't kill the SSO session.
For federated logout (kiosk/shared-machine scenarios where the SSO session must end too), redirect after removeUser() to ${VITE_OKTA_ISSUER}/oauth2/v1/logout?id_token_hint=…&post_logout_redirect_uri=…. You'll need to capture id_token from the OIDC user object and add the post-logout URL to Okta's Sign-out redirect URIs.
| Command | Purpose |
|---|---|
npm run dev |
Start the Vite dev server on :3000 |
npm run build |
Production build |
npm run preview |
Preview the production build |
npm run lint |
Run ESLint |
npm test |
Run the Vitest unit suite |
npm run test:watch |
Run Vitest in watch mode |
npm test runs the Vitest unit suite. The suite covers the testable units of the package: env.ts, the Apollo link chain (token attach + 401-retry-once + give-up-on-second-401), and the <RequireAuth> route guard. The end-to-end auth flow against a real Okta tenant is out of scope.
- Tokens are stored in
localStorageviaoidc-client-ts'sWebStorageStateStore. This is the standard SPA pattern; if your threat model excludes XSS, this is fine. If you have stricter requirements, swapWebStorageStateStorefor an in-memory store (sessions won't survive reloads) or move auth behind a backend-for-frontend. - Access and refresh tokens never appear in the page HTML.
<RequireAuth>is a client-side gate. The authoritative check is the GraphQL API rejecting requests without a valid bearer — never trust the SPA gate alone for sensitive data.- Sign-out is local-only — see Sign-out behavior.