Skip to main content

Frictionless Local Auth with a Mock OIDC Server

· 15 min read
Shaw Innes
Builder of things

Building modern web applications to be testable while using a best-practice IdP with OIDC can be at odds with a frictionless developer experience. The usual shortcut is to bypass the real auth flow entirely in local/dev environments - stub out a token, hardcode a fake user, skip the redirect dance. It's fast, but it means your local environment is no longer exercising the actual authentication and authorization path your app relies on in production, and that's exactly where subtle bugs like to hide: token validation quirks, issuer/audience mismatches, claims mapping, silent renew, redirect URIs, and so on.

This short guide covers how to use mock-oauth2-server to issue real JWTs through an actual OIDC flow in local development - the SPA (React) goes through the full authorization code flow against the mock IdP, and the backend (.NET API) validates the resulting tokens the same way it would against Okta in production. Nothing about the auth code path itself is short-circuited; only the identity provider is swapped.

Frontend (React)

The frontend has no dev/prod branching in code - it's an OIDC public client (oidc-client-ts + react-oidc-context) pointed at whichever authority is in the env vars. Swapping mock IdP for Auth0 or Authentik is just swapping env values. This section assumes a standard Vite + React SPA and walks through everything needed to bolt this onto a vanilla app: packages, env vars, the OIDC config module, and wiring main.tsx.

Install

npm install oidc-client-ts react-oidc-context
  • oidc-client-ts - the underlying OIDC/OAuth2 protocol client (handles the authorization code + PKCE flow, token storage, silent renew, etc). Framework-agnostic.
  • react-oidc-context - a thin React wrapper around it: an AuthProvider context provider plus a useAuth() hook and a withAuthenticationRequired HOC for guarding routes.

If you're not on Vite, swap import.meta.env.VITE_* below for whatever your bundler exposes (process.env.REACT_APP_* for CRA, etc) - everything else is bundler-agnostic.

Env vars

.env.example:

VITE_OIDC_AUTHORITY=http://localhost:8090/default
VITE_OIDC_CLIENT_ID=api-dev
VITE_OIDC_SCOPE=openid profile email
  • VITE_OIDC_AUTHORITY - the IdP's issuer URL. The app fetches {authority}/.well-known/openid-configuration from this at startup to discover the authorize/token/jwks endpoints, so it must be reachable from the browser.
  • VITE_OIDC_CLIENT_ID - the public client ID registered with the IdP (or with mock-oauth2-server, which accepts any client ID by default).
  • VITE_OIDC_SCOPE - space-separated OIDC/OAuth scopes to request. openid is mandatory; profile/email populate user.profile fields; add any API-specific scopes your backend expects (e.g. api.read).

OIDC config module

src/lib/oidc-config.ts - centralises the UserManagerSettings so both the AuthProvider (in main.tsx) and any code that needs a standalone UserManager (e.g. an axios interceptor) share one source of truth:

import { WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts'

const authority = import.meta.env.VITE_OIDC_AUTHORITY
const clientId = import.meta.env.VITE_OIDC_CLIENT_ID

// Lets the UI degrade gracefully (rather than throw at import time) if the
// build/deploy pipeline forgot to inject these - see AuthConfigGuard below.
export const isOidcConfigured = Boolean(authority && clientId)

// Support the app being served from a sub-path (e.g. behind a reverse proxy
// at /app/); redirect URIs must match exactly what's registered with the IdP.
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '')

export const oidcConfig: UserManagerSettings = {
authority,
client_id: clientId,
redirect_uri: `${window.location.origin}${basePath}/callback`,
post_logout_redirect_uri: `${window.location.origin}${basePath}/sign-in`,
// Requires a handler at this URI that calls `signinSilentCallback()` - oidc-client-ts
// loads it in a hidden iframe and expects the result posted back to the parent window.
// See the oidc-client-ts docs on silent renew: https://authts.github.io/oidc-client-ts/
silent_redirect_uri: `${window.location.origin}${basePath}/silent-renew`,
response_type: 'code', // authorization code + PKCE (oidc-client-ts adds PKCE automatically for public clients)
scope: import.meta.env.VITE_OIDC_SCOPE ?? 'openid profile email',
automaticSilentRenew: true,
loadUserInfo: true,
// sessionStorage (not localStorage) so tokens don't persist across tabs/restarts.
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
}

Note there's no standalone UserManager instantiated here - AuthProvider creates and owns one internally from these settings. Only construct your own new UserManager(oidcConfig) if you need to call OIDC methods (e.g. getUser()) somewhere outside React, such as an API client module.

main.tsx

This is the entry point: the AuthProvider wrapper, the router, and onSigninCallback to strip the code/state query params from the URL after redirect (without this, refreshing the callback page re-submits a stale code and errors). Routing itself is left to App.tsx below - the only requirement is that whatever redirect_uri points at renders inside the AuthProvider tree.

import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { AuthProvider, type AuthProviderProps } from 'react-oidc-context'
import { oidcConfig } from './lib/oidc-config'
import { AuthConfigGuard } from './components/auth-config-guard'
import App from './App'
import './index.css'

const onSigninCallback: AuthProviderProps['onSigninCallback'] = () => {
// Remove the ?code=&state= query params oidc-client-ts appends after
// the IdP redirects back, so a page refresh doesn't resubmit a used code.
window.history.replaceState({}, document.title, window.location.pathname)
}

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider {...oidcConfig} onSigninCallback={onSigninCallback}>
<AuthConfigGuard />
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</React.StrictMode>,
)

react-oidc-context handles the /callback exchange itself (via onSigninCallback) - it doesn't need a dedicated route/page in the router, just somewhere for redirect_uri to point that's inside the AuthProvider tree. A minimal App.tsx using the hook and the route guard HOC looks like:

import { Routes, Route } from 'react-router-dom'
import { useAuth, withAuthenticationRequired } from 'react-oidc-context'
import Dashboard from './pages/Dashboard'

function Home() {
const auth = useAuth()

if (auth.isLoading) return <div>Loading...</div>
if (auth.error) return <div>Sign-in error: {auth.error.message}</div>

if (!auth.isAuthenticated) {
return <button onClick={() => auth.signinRedirect()}>Sign in</button>
}

return (
<div>
<p>Signed in as {auth.user?.profile.email}</p>
<button onClick={() => auth.signoutRedirect()}>Sign out</button>
</div>
)
}

// withAuthenticationRequired redirects to the IdP automatically if the
// route is hit while unauthenticated (vs. Home, which renders its own button).
const ProtectedDashboard = withAuthenticationRequired(Dashboard)

export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<ProtectedDashboard />} />
</Routes>
)
}

To attach the access token to outgoing API requests, read auth.user?.access_token (available from useAuth() inside components, or via a standalone UserManager.getUser() in a non-React module like an axios/fetch interceptor) and set it as a Bearer token on the Authorization header.

Surfacing sign-in failures

react-oidc-context swallows sign-in errors silently by default (they land on auth.error but nothing shows them to the user). src/components/auth-config-guard.tsx renders inside AuthProvider (see main.tsx above) purely to surface them as a toast - it renders nothing itself:

export function AuthConfigGuard() {
const auth = useAuth()
useEffect(() => {
if (!isOidcConfigured) {
toast.error('Sign-in is not configured for this environment (missing VITE_OIDC_AUTHORITY / VITE_OIDC_CLIENT_ID at build time).')
}
}, [])
useEffect(() => {
if (auth.error) toast.error(`Sign-in failed: ${auth.error.message}`)
}, [auth.error])
return null
}

Failing the build on missing config

vite.config.ts fails a production build loudly if the OIDC env vars are missing (dev server tolerates it unset, guarded at runtime instead):

if (command === 'build') {
const required = ['VITE_OIDC_AUTHORITY', 'VITE_OIDC_CLIENT_ID']
const missing = required.filter((key) => !(process.env[key] ?? env[key]))
if (missing.length > 0) {
throw new Error(`Missing required env var(s) for build: ${missing.join(', ')}. Set them in .env or the build environment - see .env.example.`)
}
}

Backend (.NET)

Same principle as the frontend: no if (dev) { skip auth } branching. The API always validates a real JWT via the standard ASP.NET Core JwtBearer handler; only the Authority/Audience it points at (and a couple of validation knobs explained below) change per environment. This section assumes a .NET 10 minimal-hosting Web API (WebApplication.CreateBuilder).

Config

appsettings.json (prod defaults) / appsettings.Development.json (overrides, auto-loaded when ASPNETCORE_ENVIRONMENT=Development) - config section is just Auth, no IdP-specific naming, so swapping mock IdP for Okta/Entra/whatever in prod is purely a config change:

appsettings.json:

{
"Auth": {
"Authority": "https://your-tenant.idp-corp.com/oauth2/default",
"Audience": "api"
}
}

appsettings.Development.json:

{
"Auth": {
"Authority": "http://localhost:8090/default",
"Audience": "api"
}
}
  • Auth:Authority - the IdP issuer URL. The JWT bearer handler fetches {Authority}/.well-known/openid-configuration from this at startup (and periodically thereafter) to discover the JWKS endpoint used to validate token signatures.
  • Auth:Audience - the expected aud claim; must match what the IdP (or mock-oauth2-server's requestMappings, see the Docker section below) puts in the token.

Bind these to a small options class rather than reading raw configuration["Auth:..."] strings everywhere:

// Infrastructure/Auth/AuthOptions.cs
public sealed class AuthOptions
{
public const string SectionName = "Auth";

public required string Authority { get; init; }
public required string Audience { get; init; }
}

Environment detection

Skip custom env vars like a home-grown ServerType setting - ASP.NET Core already has a first-class concept of environment, driven by the ASPNETCORE_ENVIRONMENT variable (Development / Staging / Production, defaults to Production if unset) and exposed via IHostEnvironment/IWebHostEnvironment. IHostEnvironment.IsDevelopment() is the idiomatic check - it's what WebApplicationBuilder and every ASP.NET Core middleware already use, so there's no parallel notion of "dev" to keep in sync:

# launchSettings.json (local) or docker-compose.yaml sets this
ASPNETCORE_ENVIRONMENT=Development

JWT bearer wiring

Infrastructure/Auth/AuthExtensions.cs - an IServiceCollection extension method, called once from Program.cs. Takes IHostEnvironment explicitly rather than reaching for a static/ambient value, which also makes it trivial to unit test with a fake environment:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;

namespace MyApp.Infrastructure.Auth;

public static class AuthExtensions
{
public static IServiceCollection AddAppAuth(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment)
{
services
.AddOptions<AuthOptions>()
.Bind(configuration.GetSection(AuthOptions.SectionName))
// `required` is a compile-time guarantee only - it doesn't validate bound
// config, so check the values explicitly and fail fast at startup.
.Validate(o => !string.IsNullOrWhiteSpace(o.Authority), "Auth:Authority must be set.")
.Validate(o => !string.IsNullOrWhiteSpace(o.Audience), "Auth:Audience must be set.")
.ValidateOnStart();

var authOptions = configuration.GetSection(AuthOptions.SectionName).Get<AuthOptions>()
?? throw new InvalidOperationException($"Missing '{AuthOptions.SectionName}' config section.");

var isDevelopment = environment.IsDevelopment();

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = authOptions.Authority;
options.Audience = authOptions.Audience;
options.RequireHttpsMetadata = !isDevelopment;
// Mutate the existing parameters rather than assigning a new instance -
// replacing it discards the defaults JwtBearerOptions has already set up
// (claim type mappings, clock skew, and the Audience copied in above).
options.TokenValidationParameters.ValidateIssuer = !isDevelopment;
options.TokenValidationParameters.ValidateAudience = true;
options.TokenValidationParameters.ValidateLifetime = true;
options.TokenValidationParameters.ValidateIssuerSigningKey = true;
});

services.AddAuthorization();

return services;
}
}

Program.cs wiring:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAppAuth(builder.Configuration, builder.Environment);
// ...other service registrations...

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

The reason ValidateIssuer/RequireHttpsMetadata are relaxed in dev (never in prod): mock-oauth2-server derives its iss claim from whatever Host header reached it. If the API talks to it over a Docker network alias (e.g. mock-idp:8090) while the browser talks to it via localhost:8090, the issuer string differs depending on who's asking, and strict issuer validation would fail. ValidateIssuerSigningKey, audience, and lifetime checks stay enforced in both dev and prod - only the issuer string match and HTTPS-metadata requirement are relaxed, and only when IsDevelopment() is true.

Docker

mock-oauth2-server runs as just another service in the stack - no special integration beyond exposing its port and pointing the other services' Auth/VITE_OIDC_* env vars at it. This section covers the seed config that defines your dev users/claims, the compose wiring, and how to run it standalone if you're not using compose at all.

Seed config

mock-idp/mock-oauth2-config.json - seeds the aud claim on every issued token, and maps login_hint values to specific user claims. Add one requestMappings entry per dev persona you want to sign in as:

{
"interactiveLogin": true,
"httpServer": "NettyWrapper",
"tokenCallbacks": [
{
"issuerId": "default",
"tokenExpiry": 3600,
"requestMappings": [
{ "requestParam": "grant_type", "match": "authorization_code", "claims": { "aud": ["api"] } },
{ "requestParam": "login_hint", "match": "alice@example.com", "claims": { "sub": "alice@example.com", "email": "alice@example.com", "name": "Alice Johnson" } },
{ "requestParam": "login_hint", "match": "bob@example.com", "claims": { "sub": "bob@example.com", "email": "bob@example.com", "name": "Bob Smith" } }
]
}
]
}
  • issuerId: "default" - becomes the path segment in the authority URL (http://localhost:8090/default); it doesn't have to be "default", just match what Auth:Authority/VITE_OIDC_AUTHORITY point at.
  • The first mapping stamps every authorization-code token with aud: ["api"], matching Auth:Audience on the backend.
  • Each login_hint mapping fires when that exact string is entered on the mock IdP's login page (see Signing in, below) and controls the claims (sub, email, name, or any custom claim) baked into the resulting JWT - this is how you get a JWT for "Alice" or "Bob" without a real user store.

Compose service

docker-compose.yaml (wherever it lives in the repo) - add a mock-idp service, then point the backend (app) and frontend at it. Note the asymmetry: app reaches it via the Docker network alias mock-idp:8090, while frontend/the browser reaches it via localhost:8090 (the browser isn't on the Docker network) - that's the source of the differing-issuer behaviour handled in AuthExtensions.cs above, and the reason ValidateIssuer is relaxed only in dev.

services:
app:
build: ./backend
environment:
- Auth__Authority=http://mock-idp:8090/default
- Auth__Audience=api
- ASPNETCORE_ENVIRONMENT=Development
depends_on:
mock-idp:
condition: service_healthy

frontend:
build: ./frontend
environment:
- VITE_OIDC_AUTHORITY=http://localhost:8090/default
- VITE_OIDC_CLIENT_ID=api-dev

mock-idp:
image: ghcr.io/navikt/mock-oauth2-server:6.0.2
container_name: mock-idp
restart: unless-stopped
ports:
- "8090:8090"
volumes:
- ./mock-idp/mock-oauth2-config.json:/config/mock-oauth2-config.json:ro
environment:
JSON_CONFIG_PATH: /config/mock-oauth2-config.json
SERVER_PORT: 8090
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://localhost:8090/default/.well-known/openid-configuration || exit 1" ]
interval: 5s
timeout: 3s
retries: 10

The healthcheck matters here, not just cosmetically: depends_on: condition: service_healthy on app means the backend won't start (and won't fail its own startup by trying to fetch OIDC discovery metadata from a server that isn't up yet) until mock-idp is actually accepting requests.

Running it standalone (outside compose)

If the rest of the stack isn't containerized, or you just want the mock IdP running on its own, skip compose and run the image directly with the same config file and port mapping:

docker run -d --name mock-idp -p 8090:8090 \
-v "$(pwd)/mock-idp/mock-oauth2-config.json:/config/mock-oauth2-config.json:ro" \
-e JSON_CONFIG_PATH=/config/mock-oauth2-config.json \
-e SERVER_PORT=8090 \
ghcr.io/navikt/mock-oauth2-server:6.0.2

Point Auth:Authority/VITE_OIDC_AUTHORITY at http://localhost:8090/default on both sides in this mode - there's no Docker network alias to worry about, so the issuer string the backend sees matches what the browser sees, and ValidateIssuer doesn't need relaxing even in dev (though it doesn't hurt to leave the IsDevelopment() gate in place for when you do switch to compose).

Signing in

auth.signinRedirect() in the frontend redirects to the mock IdP. Because interactiveLogin is true, it serves a login page rather than issuing a token straight away - enter one of the emails seeded in requestMappings above (alice@example.com or bob@example.com) and the resulting JWT carries exactly the claims mapped to it (sub, email, name) plus the aud: ["api"] the backend is configured to expect.

Adding a persona is a config edit and a container restart: another login_hint entry with whatever claims that user should carry. There's no user store and no password - the seed file is the whole source of truth. If your application needs its own records to line up with those identities (roles, org membership, and so on), keep that provisioning in the application and gate it on environment.IsDevelopment(), rather than adding a bespoke config flag.

Testing Token Lifecycles

Swapping the IdP rather than the auth code path also buys you a test rig for the parts of OIDC that are genuinely awkward to exercise against a real provider. Token lifetime is per-issuer config here (tokenExpiry, in seconds), so dropping it to 10 turns silent renew, refresh, and 401-then-retry into things you watch happen in a few seconds rather than an hour - and nobody is going to let you dial the token lifetime on a production tenant down to ten seconds to check your axios interceptor. Set rotateRefreshToken: true at the top level of the seed config and every refresh issues a new token and invalidates the old one, which is the cheapest way to prove your client actually persists the rotated value: replay the superseded token and you get back the same invalid_grant a real IdP returns for an expired one. tokenProvider.systemTime pins the server's clock, so you can mint tokens that are already expired and confirm the API rejects them rather than assuming it would. The one case you can't reach by waiting is refresh token expiry itself - the mock holds them in an in-memory map with no TTL, so they live until they're rotated away or the container restarts.

Developer Ergonomics

Developer ergonomics matter as much as the correctness of the auth flow itself. A new contributor should be able to clone the repo, bring the stack up, and sign in without reading a setup doc or hand-editing config - that's what the appsettings.Development.json / .env.example defaults and the compose-wired mock-idp service are for: safe, working-out-of-the-box values that never touch a real IdP or real credentials. The flip side is that deviating from those defaults - pointing at a real IdP, changing seeded users and claims, running against a teammate's staging environment - has to be just as obvious: a handful of clearly named, well-documented environment variables and config keys (Auth:Authority, VITE_OIDC_AUTHORITY, and so on) rather than code changes or hidden flags.

"It just works" by default, with the escape hatches in plain sight, is what keeps a real-auth-in-dev setup like this one from becoming friction that people route around.