DEV Community

OAuthSonas: A Tiny OpenID Connect Provider for Playwright E2E Tests

The Problem

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 /login route that sets a session cookie.
  • Pointed your test suite at a real Auth0 tenant and prayed the rate limits wouldn't bite.
  • 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

Introducing oauthsonas

Meet oauthsonas (that's "OAuth-sonas" - personas for OAuth). It 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/callback route 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 substitute localhost in 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: v*****@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("n******@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 oauthsonas the same way it would against Auth0.
  • JWKS rotation after server restart - restart the oauthsonas process mid-test and verify your client refetches keys (it should - the kid changes).
  • aud / iss validation - set OIDC_AUDIENCE wrong and watch your middleware reject the token.
  • Scope downscoping on refresh - request offline_access, then refresh with a narrower scope; oauthsonas correctly 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 your post_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: CON

Comments

No comments yet. Start the discussion.