If you've ever wired up end-to-end tests for an app that uses OpenID Connect, you've probably done one of these:
- stubbed the OIDC library entirely and shipped a fake
/loginroute that sets a session cookie, - pointed your test suite at a real Auth0 tenant and prayed the rate limits wouldn't bite,
- or written a half-baked mock that returns a hardcoded JWT and silently broke every token-validation rule in your middleware.
All three are bad. The first one means your E2E tests never exercise the path your users actually take. The second is flaky, slow, and leaks credentials. The third gives you green tests over broken code.
At Optimi we run edge infrastructure for enterprise customers, and our dashboards authenticate against a real OIDC provider. We wanted E2E tests that hit the actual browser Authorization Code flow — discovery, PKCE, callback, JWKS validation, userinfo, logout — without standing up a full Auth0 tenant per CI run. So we built one and open-sourced it.
💡 Project link: github.com/optimiweb/oauthsonas
Meet oauthsonas
oauthsonas (that's "OAuth-sonas" — personas for OAuth) is a small, in-memory OpenID Connect provider for local development and integration tests. It implements:
- A real browser Authorization Code flow with S256 PKCE
- Rotating refresh tokens (with reuse detection)
- RS256 JWT access and ID tokens
- Discovery, JWKS, userinfo, and RP-initiated logout
- Configurable personas — pick who you "are" with one click
- Stable, versioned DOM selectors for browser automation
It is deliberately production-unsafe — no database, no real authentication, single-replica only, in-memory state lost on restart. That's the point. It's a drop-in stand-in for your real IdP when your real IdP is the wrong tool for the job.
A new 2048-bit RSA key is generated on every start and published at /.well-known/jwks.json. Your relying party validates tokens against that JWKS the same way it would against Auth0's — no special test mode, no shortcuts.
Why this is better than mocking
When you stub the OIDC library in your tests, you silently disconnect your test from the protocol your application actually runs. State preservation, PKCE verifier handling, aud/iss checking, JWKS rotation, refresh-token rotation — none of it gets exercised.
oauthsonas keeps all of that real. Your app speaks OIDC the same way it does in production; only the human at the keyboard is replaced by a persona picker.
That gives you:
- Real discovery URL for your middleware to pull configuration from
- Real JWKS for RS256 signature validation
- Real callback handling — your
/auth/callbackroute runs unchanged - Real refresh-token rotation with reuse detection
- Real RP-initiated logout with
id_token_hint - Reproducible RBAC personas (
roles,org_id,memberships) as JWT claims
Step 1 — Run the provider
Install the Go version declared in go.mod and either run from a checkout or install the command directly:
go install github.com/optimiweb/oauthsonas/cmd/oauthsonas@latest
Then start it with the example config:
oauthsonas --config config.example.yaml
Or, if you prefer containers:
podman run --rm -p 127.0.0.1:8181:8181 \
-e OAUTHSONAS_ALLOW_NON_LOOPBACK=true \
ghcr.io/optimiweb/oauthsonas:latest
Verify it's alive:
curl http://127.0.0.1:8181/.well-known/openid-configuration
{
"issuer": "http://127.0.0.1:8181",
"authorization_endpoint": "http://127.0.0.1:8181/oauth2/auth",
"token_endpoint": "http://127.0.0.1:8181/oauth2/token",
"jwks_uri": "http://127.0.0.1:8181/.well-known/jwks.json",
"userinfo_endpoint": "http://127.0.0.1:8181/userinfo",
"end_session_endpoint": "http://127.0.0.1:8181/logout",
...
}
⚠️ The default issuer is exactly
http://127.0.0.1:8181. Do not substitutelocalhostin your client config — OIDC issuer matching is exact.
Step 2 — Point your app at it
Whatever OIDC library your app uses, configure it with the equivalent of:
OIDC_AUTHORITY=http://127.0.0.1:8181
OIDC_CLIENT_ID=dashboard
OIDC_REDIRECT_URI=http://127.0.0.1:5173/auth/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://127.0.0.1:5173/
OIDC_SCOPE="openid profile email"
OIDC_AUDIENCE=https://api.example.test
The example client defined in config.example.yaml is public and requires S256 PKCE — so set your library up for a public client with a code verifier. No client secret to leak, no symmetric JWT to forge.
When a developer hits your login button, they'll be redirected to oauthsonas's authorization page, where they pick a configured persona — say Acme Administrator — and the redirect comes back to your callback with a normal authorization code. Your library then exchanges it and validates the RS256 tokens against the published JWKS, exactly as in production.
Step 3 — (Optional) Define the personas you test against
The default config.example.yaml ships platform staff, account managers, and per-customer admin/viewer personas — already a useful RBAC vocabulary. To add your own:
personas:
- id: delta-viewer
subject: oauthsonas|delta-viewer
email: viewer@delta.example.test
name: Delta Viewer
organization_id: org_delta
roles: [customer-viewer]
Roles are emitted as a JWT claim (roles by default). oauthsonas never expands roles into permissions — that's your app's job, as it should be. You can rename claims via a top-level claims: block (useful for Auth0-style namespaced keys like https://example.com/roles), or enforce a role vocabulary with allowed_roles. Validate a config without starting the server:
oauthsonas --config config.yaml --check-config
For tests, prefer one persona per RBAC branch you care about: one platform admin, one org-scoped customer admin, one org-scoped viewer, one multi-org staff member. Same matrix you'd use in your test suite, just expressed as YAML.
Step 4 — Drive the flow from Playwright
This is the fun part. The persona picker exposes stable, versioned selectors specifically for browser automation:
| Element | Selector |
|---|---|
| Persona form container | form[data-testid="persona-form-<id>"] |
| CSRF hidden input | input[data-testid="csrf-input"] |
| Interaction ID field | input[data-testid="interaction-id-input"] |
| Submit button | button[data-testid="persona-select-<id>"] |
That's the contract. oauthsonas will not change it without a major version bump — see the handler godoc for the full versioned form contract on POST /oauth2/auth/select.
Here's a minimal Playwright login helper you can drop into a fixture. It assumes your app's login button lives at / and redirects to the IdP.
import { test as base, expect, type Page } from "@playwright/test";
const ISSUER = "http://127.0.0.1:8181";
export type Persona =
| "platform-admin"
| "operator"
| "account-manager-acme"
| "acme-admin"
| "acme-viewer"
| "bravo-admin";
export async function signInAs(page: Page, persona: Persona) {
// 1. Trigger the OIDC flow from your app. The app redirects to /oauth2/auth
// on oauthsonas, which renders the persona picker.
await page.goto("/");
await page.getByRole("button", { name: /sign in/i }).click();
// 2. We're now on the oauthsonas authorization page. Make sure we arrived.
await page.waitForURL(new RegExp(`^${ISSUER}/oauth2/auth\\??`));
// 3. Click the persona button — the form is pre-filled with CSRF + interaction_id.
await page
.locator(`button[data-testid="persona-select-${persona}"]`)
.click();
// 4. oauthsonas responds with a 302 to your /auth/callback, which exchanges
// the code and redirects back to your authenticated landing page.
await page.waitForURL(/\/(app|dashboard|home)$/);
}
export const test = base.extend<{ authedPage: Page }>({
authedPage: async ({ page }, use) => {
await signInAs(page, "acme-admin");
await use(page);
},
});
export { expect };
A test then becomes trivially readable:
import { test, expect } from "../fixtures/auth";
test("an org admin can invite a new member", async ({ authedPage: page }) => {
await page.goto("/app/settings/members");
await page.getByRole("button", { name: /invite member/i }).click();
await page.getByLabel("Email").fill("newhire@acme.example.test");
await page.getByRole("button", { name: /send invite/i }).click();
await expect(page.getByText(/invitation sent/i)).toBeVisible();
});
test("a viewer cannot invite members", async ({ page }) => {
await signInAs(page, "acme-viewer");
await page.goto("/app/settings/members");
await expect(page.getByRole("button", { name: /invite member/i })).toBeHidden();
});
No mocks. No injected cookies. No bypassed middleware. Your token-validation code, your session middleware, your aud/iss checks — all run for real.
Step 5 — Clean up: logout
RP-initiated logout works too, because your test should be able to reset identity between steps:
export async function signOut(page: Page) {
// Your app's logout route should 302 to the IdP's end_session_endpoint
// with an id_token_hint and a post_logout_redirect_uri.
await page.goto("/auth/logout");
await page.waitForURL("/"); // back to the unauthenticated landing page
}
oauthsonas derives the client from the aud claim of the id_token_hint, so logout works with or without an explicit client_id — exactly mirroring Auth0 and Keycloak behavior.
A Playwright project setup that works
Bundle Playwright tests with oauthsonas started in globalSetup and torn down in globalTeardown. sketch:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
globalSetup: require.resolve("./tests/global-setup"),
globalTeardown: require.resolve("./tests/global-teardown"),
use: {
baseURL: "http://127.0.0.1:5173",
trace: "retain-on-failure",
},
webServer: {
command: "npm run dev",
port: 5173,
reuseExistingServer: !process.env.CI,
},
});
// tests/global-setup.ts
import { spawn } from "node:child_process";
let child: ReturnType<typeof spawn>;
export default async function () {
child = spawn("oauthsonas", ["--config", "oauthsonas.yaml"], {
stdio: "inherit",
});
const ok = await waitFor("http://127.0.0.1:8181/readyz", 30_000);
if (!ok) throw new Error("oauthsonas did not become ready");
process.env.OAUTHSONAS_PID = String(child.pid);
}
async function waitFor(url: string, timeoutMs: number) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return true;
} catch {}
await new Promise((r) => setTimeout(r, 200));
}
return false;
}
// tests/global-teardown.ts
export default async function () {
const pid = Number(process.env.OAUTHSONAS_PID);
if (pid) process.kill(pid, "SIGTERM");
}
In CI, use the published container image from GitHub Container Registry instead of installing Go:
docker pull ghcr.io/optimiweb/oauthsonas:latest
docker run -d --rm \
-e OAUTHSONAS_ALLOW_NON_LOOPBACK=true \
-p 127.0.0.1:8181:8181 \
ghcr.io/optimiweb/oauthsonas:latest
Keep the published port loopback-bound, even in CI. The OAUTHSONAS_ALLOW_NON_LOOPBACK=true env only acknowledges that the container itself binds 0.0.0.0 — Docker needs that for port-forwarding, but the host-side port should still be 127.0.0.1 only. There is no reason ever to expose oauthsonas to the internet.
What you actually want to test
With the real flow in place, here's a non-exhaustive list of regressions your E2E suite now catches that mocking would have hidden:
-
PKCE verifier mismatch — if your library mis-handles the verifier, the token exchange fails against
oauthsonasthe same way it would against Auth0. -
JWKS rotation after server restart — restart the
oauthsonasprocess mid-test and verify your client refetches keys (it should — thekidchanges). -
aud/issvalidation — setOIDC_AUDIENCEwrong and watch your middleware reject the token. -
Scope downscoping on refresh — request
offline_access, then refresh with a narrower scope;oauthsonascorrectly narrows the new grant. - Refresh-token reuse detection — replay a refresh token and observe the rejection.
- RBAC matrix — same flow, different personas, one assertion per branch.
-
Logout — confirm
id_token_hint-based RP-initiated logout redirects back to yourpost_logout_redirect_uri.
These are precisely the bugs that mock auth hides. None of them are exotic; all of them have bitten someone I know in production.
What oauthsonas is not
This is worth being explicit about, because the temptation to "just use it in dev" can lead you astray:
- Not for production. In-memory state, no real authentication, no database. A restart invalidates every token and every interaction.
- Single-replica only. All protocol state is process-local. Don't try to put it behind a load balancer — JWKS will mismatch and cookies will fail.
- No password flow, no user provisioning, no Auth0 Management API. It's a test fixture, not a tiny Auth0 replacement.
- No persistent identities. Personas are YAML-defined; you can't "sign up" a new user through it.
Read SECURITY.md in the repo before you hack on it — the design is intentionally unsafe for some classes of deployment.
Roadmap-ish
oauthsonas is intentionally minimal and we plan to keep it that way. The roadmap is removing rough edges, not adding features. Things you can expect to improve:
- Better logging attributes for correlating Playwright runs with OIDC events
- More ergonomic persona picker HTML for screen readers (already accessible, but we're picky)
- Tighter CI story around the published container image
Things we will not accept as PRs: databases, password authentication, multi-replica state, user provisioning, a Management API. There's a wide market of real IdPs for that.
Try it, star it, break it
Fair: this is a small tool solving a narrow problem. If your E2E tests currently mock auth and you've been quietly unhappy about it, give oauthsonas a try:
- Source & docs: github.com/optimiweb/oauthsonas
-
Container image:
ghcr.io/optimiweb/oauthsonas:latest - License: MIT
- Contributing: CONTRIBUTING.md — we read every issue and PR.
If you find a protocol edge your real IdP handles and oauthsonas doesn't, that's a bug — please open an issue with a minimal reproducer. We want this to be the last test-IdP you ever have to write.
oauthsonasis built by the team at Optimi, where we orchestrate enterprise edge infrastructure (CDN, DNS, security, technical SEO/GEO) for premium brands. We open-source small, sharp tools when they solve a problem we'd otherwise have to keep solving — and we'd rather they lived in the community than on a PowerPoint slide.
Found this useful? Drop a ⭐ on the repo so other people find it too.
Top comments (0)