diff --git a/docs/marketing/landing-20260305.jpg b/docs/marketing/landing-20260305.jpg
new file mode 100644
index 0000000..50e52b7
Binary files /dev/null and b/docs/marketing/landing-20260305.jpg differ
diff --git a/docs/marketing/landing-20260305.png b/docs/marketing/landing-20260305.png
new file mode 100644
index 0000000..9b0e978
Binary files /dev/null and b/docs/marketing/landing-20260305.png differ
diff --git a/package.json b/package.json
index 18b7211..e1a92a2 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"dotenv": "^17.3.1",
+ "geist": "^1.7.0",
"jose": "6.1.3",
"lucide-react": "0.475.0",
"nanoid": "5.1.6",
@@ -67,7 +68,6 @@
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
- "jsdom": "24.1.3",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
@@ -77,6 +77,7 @@
"dotenv-cli": "11.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "jsdom": "24.1.3",
"openid-client": "6.8.2",
"playwright": "1.58.2",
"postcss": "^8",
diff --git a/playwright.config.ts b/playwright.config.ts
index 831fe47..d25e668 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
@@ -11,23 +12,47 @@ const ldLibrarySegments = (process.env.PLAYWRIGHT_LD_LIBRARY_PATH ?? "")
path.isAbsolute(segment) ? segment : path.resolve(projectRoot, segment),
);
+const headlessShellPath = path.resolve(
+ projectRoot,
+ ".playwright-browsers",
+ "chromium_headless_shell-1208",
+ "chrome-linux",
+ "headless_shell",
+);
+
const chromiumLdLibraryPath = [
"/usr/lib/x86_64-linux-gnu",
"/lib/x86_64-linux-gnu",
...ldLibrarySegments,
- path.resolve(
- projectRoot,
- ".playwright-browsers",
- "chromium_headless_shell-1208",
- "chrome-headless-shell-linux64",
- ),
+ path.dirname(headlessShellPath),
]
.filter(Boolean)
.join(":");
+const chromiumExecutablePath = (() => {
+ if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE) {
+ return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE;
+ }
+
+ if (process.arch === "x64" && fs.existsSync(headlessShellPath)) {
+ return headlessShellPath;
+ }
+
+ const fullChromiumBinary = path.resolve(
+ projectRoot,
+ ".playwright-browsers",
+ "chromium-1208",
+ "chrome-linux",
+ "chrome",
+ );
+
+ return fullChromiumBinary;
+})();
+
export default defineConfig({
testDir: "tests/e2e",
timeout: 120_000,
+ retries: process.env.CI ? 1 : 0,
expect: {
timeout: 10_000,
},
@@ -35,6 +60,14 @@ export default defineConfig({
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
trace: "on-first-retry",
launchOptions: {
+ executablePath: chromiumExecutablePath,
+ args: [
+ "--disable-dev-shm-usage",
+ "--no-sandbox",
+ "--disable-gpu",
+ "--use-gl=swiftshader",
+ "--no-zygote",
+ ],
env: {
...process.env,
LD_LIBRARY_PATH: chromiumLdLibraryPath,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5d1c15..a9d3256 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
dotenv:
specifier: ^17.3.1
version: 17.3.1
+ geist:
+ specifier: ^1.7.0
+ version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
jose:
specifier: 6.1.3
version: 6.1.3
@@ -2665,6 +2668,11 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+ geist@1.7.0:
+ resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==}
+ peerDependencies:
+ next: '>=13.2.0'
+
generate-function@2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
@@ -6768,6 +6776,10 @@ snapshots:
functions-have-names@1.2.3: {}
+ geist@1.7.0(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
+ dependencies:
+ next: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+
generate-function@2.3.1:
dependencies:
is-property: 1.0.2
diff --git a/scripts/capture-landing-screenshot.sh b/scripts/capture-landing-screenshot.sh
new file mode 100755
index 0000000..a9d4550
--- /dev/null
+++ b/scripts/capture-landing-screenshot.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+paths=(
+ "$(nix eval --raw nixpkgs#glib.out)/lib"
+ "$(nix eval --raw nixpkgs#libXfixes.out)/lib"
+ "$(nix eval --raw nixpkgs#nspr.out)/lib"
+ "$(nix eval --raw nixpkgs#nss.out)/lib"
+ "$(nix eval --raw nixpkgs#atk.out)/lib"
+ "$(nix eval --raw nixpkgs#cairo.out)/lib"
+ "$(nix eval --raw nixpkgs#pango.out)/lib"
+ "$(nix eval --raw nixpkgs#gdk-pixbuf.out)/lib"
+ "$(nix eval --raw nixpkgs#gtk3.out)/lib"
+ "$(nix eval --raw nixpkgs#dbus.lib)/lib"
+ "$(nix eval --raw nixpkgs#cups.lib)/lib"
+ "$(nix eval --raw nixpkgs#expat.out)/lib"
+ "$(nix eval --raw nixpkgs#libxcb.out)/lib"
+ "$(nix eval --raw nixpkgs#libxkbcommon.out)/lib"
+ "$(nix eval --raw nixpkgs#libX11.out)/lib"
+ "$(nix eval --raw nixpkgs#libXcomposite.out)/lib"
+ "$(nix eval --raw nixpkgs#libXdamage.out)/lib"
+ "$(nix eval --raw nixpkgs#libXext.out)/lib"
+ "$(nix eval --raw nixpkgs#libXrandr.out)/lib"
+ "$(nix eval --raw nixpkgs#libgbm.out)/lib"
+ "$(nix eval --raw nixpkgs#alsa-lib.out)/lib"
+)
+
+LD_JOINED=$(printf "%s:" "${paths[@]}")
+LD_JOINED=${LD_JOINED%:}
+
+export PLAYWRIGHT_BROWSERS_PATH=".playwright-browsers"
+export LD_LIBRARY_PATH="${LD_JOINED}:${LD_LIBRARY_PATH:-}"
+
+start_dev_server() {
+ pnpm dev --hostname 127.0.0.1 --port 3000 >/tmp/mockauth-dev.log 2>&1 &
+ DEV_PID=$!
+ trap 'kill ${DEV_PID} >/dev/null 2>&1 || true' EXIT
+
+ for _ in $(seq 1 120); do
+ if curl -fsS http://127.0.0.1:3000 >/dev/null; then
+ echo "Dev server ready"
+ return 0
+ fi
+ sleep 1
+ done
+
+ echo "Dev server failed to become ready" >&2
+ return 1
+}
+
+main() {
+ start_dev_server
+ pnpm exec node -e "const path = require('node:path'); const { chromium } = require('playwright'); (async () => { const executablePath = path.resolve('.playwright-browsers', 'chromium-1208', 'chrome-linux', 'chrome'); const browser = await chromium.launch({ headless: true, executablePath, args: ['--no-sandbox'] }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); await page.goto('http://127.0.0.1:3000', { waitUntil: 'networkidle' }); await page.screenshot({ path: 'docs/marketing/landing-20260305.png', fullPage: true }); await browser.close(); })().catch(err => { console.error(err); process.exit(1); });"
+}
+
+main
diff --git a/scripts/run-playwright-suite.mjs b/scripts/run-playwright-suite.mjs
index 5b8ed80..127a105 100755
--- a/scripts/run-playwright-suite.mjs
+++ b/scripts/run-playwright-suite.mjs
@@ -47,6 +47,13 @@ const env = {
.join(':'),
};
+env.PLAYWRIGHT_LD_LIBRARY_PATH = [
+ process.env.PLAYWRIGHT_LD_LIBRARY_PATH ?? '',
+ process.env.LD_LIBRARY_PATH ?? '',
+]
+ .filter(Boolean)
+ .join(':');
+
const runSpec = (spec) =>
new Promise((resolve, reject) => {
process.stdout.write(`\n=== Running ${spec} ===\n`);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 4205c45..eeae91f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { GeistMono } from "geist/font/mono";
+import { GeistSans } from "geist/font/sans";
import "./globals.css";
@@ -7,16 +8,6 @@ import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/toaster";
import { cn } from "@/lib/utils";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
export const metadata: Metadata = {
title: "Mockauth",
description: "Multi-tenant mock OIDC provider for QA environments",
@@ -24,8 +15,8 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
-
+
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index aed1743..d90f581 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
-import Link from "next/link";
const frictionPoints = [
{
@@ -95,24 +94,28 @@ const excellenceItems: { title: string; description: ReactNode }[] = [
];
const primaryHeroButtonClasses =
- "inline-flex items-center justify-center rounded-full bg-white px-8 py-4 text-base font-semibold text-indigo-700 shadow-lg shadow-indigo-950/40 transition hover:bg-indigo-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white";
+ "inline-flex items-center justify-center rounded-full bg-white px-8 py-4 text-base font-semibold text-indigo-700 shadow-lg shadow-indigo-900/40 transition hover:bg-indigo-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white";
const secondaryHeroLinkClasses =
- "inline-flex items-center justify-center rounded-full border border-white/70 bg-white/10 px-8 py-4 text-base font-semibold text-white/90 shadow-lg shadow-indigo-950/20 transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white";
+ "inline-flex items-center justify-center rounded-full border border-white/70 bg-white/10 px-8 py-4 text-base font-semibold text-white/90 shadow-lg shadow-indigo-900/20 transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white";
export default function Home() {
const currentYear = new Date().getFullYear();
+ const productionFeatureSplitIndex = Math.ceil(productionFeatures.length / 2);
+ const productionFeatureColumns = [
+ productionFeatures.slice(0, productionFeatureSplitIndex),
+ productionFeatures.slice(productionFeatureSplitIndex),
+ ];
+ const developerFeatureSplitIndex = Math.ceil(developerFeatures.length / 2);
+ const developerFeatureColumns = [
+ developerFeatures.slice(0, developerFeatureSplitIndex),
+ developerFeatures.slice(developerFeatureSplitIndex),
+ ];
return (
-
+
-
-
- MockAuth
-
+
-
-
-
-
-
- Ephemeral identity test rig
-
-
- MockAuth
-
-
- Frictionless, production-realistic OIDC flows tailored for local development and CI pipelines. Launch a
- deterministic provider in seconds and validate every redirect, token, and scope with confidence.
-
-
-
-
- Sign in
-
+
+
+
+
+
+
+ Ephemeral identity test rig
+
+
+ MockAuth
+
+
+ Frictionless, production-realistic OIDC flows tailored for local development and CI pipelines. Launch a deterministic provider in seconds and validate every redirect, token, and scope with confidence.
+
+
-
-
-
-
-
-
The Auth Testing Standard
-
-
- A purpose-built, standards-compliant OIDC identity provider designed for testing. It simulates the behavior of a
- production authentication server, allowing you to validate sign-ins, token handling, and redirect logic in
- isolated environments without relying on real user accounts or external services. It is optimized for QA, local
- development, and ephemeral CI pipelines where you need reliable, repeatable, and clean auth states.
-
-
-
-
+
-
-
-
Eliminate Auth Friction
-
- {frictionPoints.map((point) => (
-
-
{point.title}
-
{point.description}
-
- ))}
+
+
+
The Auth Testing Standard
+
+
+ A purpose-built, standards-compliant OIDC identity provider designed for testing. It simulates the behavior of a
+ production authentication server, allowing you to validate sign-ins, token handling, and redirect logic in isolated
+ environments without relying on real user accounts or external services. It is optimized for QA, local development,
+ and ephemeral CI pipelines where you need reliable, repeatable, and clean auth states.
+
+
-
-
+
-
-
-
-
Key Features — Production-Grade Standards
-
- {productionFeatures.map((feature) => (
+
+
+
Eliminate Auth Friction
+
+ {frictionPoints.map((point) => (
-
{feature.title}
-
{feature.description}
+
{point.title}
+
{point.description}
))}
-
-
Key Features — Developer Experience
-
- {developerFeatures.map((feature) => (
+
+
+
+
+
+
+ Key Features — Production-Grade Standards
+
+
+ {productionFeatureColumns
+ .filter((column) => column.length > 0)
+ .map((column, columnIndex) => (
+
+ {column.map((feature) => (
+
+
+ ✓
+
+
+
{feature.title}
+
{feature.description}
+
+
+ ))}
+
+ ))}
+
+
+
+
+ Key Features — Developer Experience
+
+
+ {developerFeatureColumns
+ .filter((column) => column.length > 0)
+ .map((column, columnIndex) => (
+
+ {column.map((feature) => (
+
+
+ ✓
+
+
+
{feature.title}
+
{feature.description}
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+
Deployable Anywhere
+
+ {deployableHighlights.map((item) => (
-
{feature.title}
-
{feature.description}
+
{item.title}
+
{item.description}
))}
-
-
+
-
-
-
Deployable Anywhere
-
- {deployableHighlights.map((item) => (
-
-
{item.title}
-
{item.description}
-
- ))}
+
+
+
Where MockAuth Excels
+
+ {excellenceItems.map((item, index) => (
+
+
+ {index + 1}
+
+
+ {item.title}: {item.description}
+
+
+ ))}
+
-
-
+
+
-
+
-
Where MockAuth Excels
-
- {excellenceItems.map((item, index) => (
-
-
- {index + 1}
-
-
- {item.title}: {item.description}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
Quick Start
-
- Drop MockAuth into your stack and run the full OIDC suite locally or in CI with a single command.
-
-
+
+
Quick Start
+
+ Drop MockAuth into your stack and run the full OIDC suite locally or in CI with a single command.
+
+
diff --git a/tests/e2e/signin-link.spec.ts b/tests/e2e/signin-link.spec.ts
index f7dc36e..157a632 100644
--- a/tests/e2e/signin-link.spec.ts
+++ b/tests/e2e/signin-link.spec.ts
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
const repoUrl = "https://github.com/agynio/mockauth";
+const directSignInUrl = "/api/auth/signin/logto?callbackUrl=/admin";
test("landing primary CTA is present with placeholder target", async ({ page }) => {
await page.goto("/");
@@ -17,4 +18,21 @@ test("landing primary CTA is present with placeholder target", async ({ page })
.getByRole("banner")
.getByRole("link", { name: "GitHub" });
await expect(headerGithub).toHaveAttribute("href", repoUrl);
+
+ await expect(page.getByRole("link", { name: "MockAuth" })).toHaveCount(0);
+ await expect(page.getByRole("link", { name: "Sign in" })).toHaveCount(0);
+});
+
+test("direct admin sign-in route lands on console", async ({ page, context, request }) => {
+ await context.clearCookies();
+ await request.delete("/api/test/logto/profile");
+ await request.post("/api/test/logto/profile", {
+ data: { email: "owner@example.test", sub: "landing-direct" },
+ });
+
+ await page.goto(directSignInUrl);
+ await expect(page.getByRole("button", { name: /logto/i })).toBeVisible();
+ await page.getByRole("button", { name: /logto/i }).click();
+ await page.waitForURL(/\/admin(\/clients)?$/);
+ await expect(page.getByRole("heading", { name: "Admin overview" })).toBeVisible();
});