test(e2e): automated tests for OIDC

This commit is contained in:
Nicolas Meienberger 2026-02-27 17:37:30 +01:00
parent b49f639044
commit 27ac83613d
12 changed files with 316 additions and 49 deletions

View file

@ -51,6 +51,7 @@ jobs:
echo "SERVER_IP=localhost" >> .env.local echo "SERVER_IP=localhost" >> .env.local
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
echo "BASE_URL=http://localhost:4096" >> .env.local echo "BASE_URL=http://localhost:4096" >> .env.local
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
- name: Start zerobyte-e2e service - name: Start zerobyte-e2e service
run: bun run start:e2e -- -d run: bun run start:e2e -- -d
@ -59,9 +60,13 @@ jobs:
run: | run: |
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done' timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
- name: Wait for Dex to be ready
run: |
timeout 30s bash -c 'until curl -sf http://localhost:5557/dex/.well-known/openid-configuration; do echo "Waiting for Dex..." && sleep 2; done'
- name: Print docker logs if failed to start - name: Print docker logs if failed to start
if: failure() if: failure()
run: docker logs zerobyte || true run: docker compose logs zerobyte-e2e dex || true
- name: Make playwright directory writable - name: Make playwright directory writable
run: sudo chmod 777 playwright/data/zerobyte.db run: sudo chmod 777 playwright/data/zerobyte.db
@ -73,7 +78,8 @@ jobs:
if: always() if: always()
run: | run: |
tree playwright tree playwright
docker logs zerobyte > playwright-report/container-logs.txt || true docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true
docker compose logs dex > playwright-report/dex-logs.txt || true
- name: Debug - print content of /test-data in container - name: Debug - print content of /test-data in container
if: failure() if: failure()

2
.gitignore vendored
View file

@ -28,6 +28,7 @@ node_modules/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
/playwright/restic.pass
playwright/.auth playwright/.auth
playwright/temp playwright/temp
@ -39,3 +40,4 @@ openapi-ts-error-*.log
.output .output
tmp/ tmp/
qa-output qa-output

View file

@ -1,5 +1,3 @@
# AGENTS.md
## Important instructions ## Important instructions
- Never create migration files manually. Always use the provided command to generate migrations - Never create migration files manually. Always use the provided command to generate migrations

View file

@ -16,7 +16,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
"bg-[size:40px_40px]", "bg-[size:40px_40px]",
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", "bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", "dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
"[mask-image:radial-gradient(ellipse_at_top,black_40%,transparent_100%)]", "[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
)} )}
/> />
<div className={cn("relative container m-auto z-10", className)}>{children}</div> <div className={cn("relative container m-auto z-10", className)}>{children}</div>

View file

@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg"> <div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{activeOrganization?.logo ? ( {activeOrganization?.logo ? (
<img <img
src={activeOrganization.logo} src={activeOrganization.logo}
@ -74,7 +74,7 @@ export function OrganizationSwitcher() {
<span className="truncate font-medium">{activeOrganization?.name}</span> <span className="truncate font-medium">{activeOrganization?.name}</span>
<span className="truncate text-xs">{organizations.length} organizations</span> <span className="truncate text-xs">{organizations.length} organizations</span>
</div> </div>
<ChevronsUpDown className="ml-auto" /> <ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent

View file

@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border/80 py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ", "bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
className, className,
)} )}
{...props} {...props}

View file

@ -61,13 +61,25 @@ services:
- DISABLE_RATE_LIMITING=true - DISABLE_RATE_LIMITING=true
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b - APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
- BASE_URL=http://localhost:4096 - BASE_URL=http://localhost:4096
- TRUSTED_ORIGINS=http://dex:5557,http://localhost:5557
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
cap_add: cap_add:
- SYS_ADMIN - SYS_ADMIN
ports: ports:
- "4096:4096" - "4096:4096"
depends_on:
- dex
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- ./playwright/data:/var/lib/zerobyte/data - ./playwright/data:/var/lib/zerobyte/data
- ./playwright/temp:/test-data - ./playwright/temp:/test-data
dex:
image: ghcr.io/dexidp/dex:latest
restart: unless-stopped
ports:
- "5557:5557"
volumes:
- ./playwright/dex-config.yaml:/etc/dex/cfg.yaml:ro
command: dex serve /etc/dex/cfg.yaml

View file

@ -1,85 +1,115 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { gotoAndWaitForAppReady } from "./helpers/page"; import { gotoAndWaitForAppReady } from "./helpers/page";
test.describe.configure({ mode: "serial" }); const testDataPath = path.join(process.cwd(), "playwright", "temp");
test.beforeAll(() => { type ScenarioNames = {
const testDataPath = path.join(process.cwd(), "playwright", "temp"); volumeName: string;
if (fs.existsSync(testDataPath)) { repositoryName: string;
for (const file of fs.readdirSync(testDataPath)) { backupName: string;
fs.rmSync(path.join(testDataPath, file), { recursive: true, force: true }); };
}
} else {
fs.mkdirSync(testDataPath, { recursive: true });
}
});
test("can backup & restore a file", async ({ page }) => { function getRunId(testInfo: TestInfo) {
await gotoAndWaitForAppReady(page, "/"); return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
await expect(page).toHaveURL("/volumes"); }
// 0. Create a test file in /test-data function getScenarioNames(runId: string): ScenarioNames {
const testDataPath = path.join(process.cwd(), "playwright", "temp"); return {
const filePath = path.join(testDataPath, "test.json"); volumeName: `Volume-${runId}`,
repositoryName: `Repo-${runId}`,
backupName: `Backup-${runId}`,
};
}
function prepareTestFile(runId: string): string {
const runPath = path.join(testDataPath, runId);
fs.mkdirSync(runPath, { recursive: true });
const filePath = path.join(runPath, "test.json");
fs.writeFileSync(filePath, JSON.stringify({ data: "test file" })); fs.writeFileSync(filePath, JSON.stringify({ data: "test file" }));
// 1. Create a local volume on /test-data return filePath;
}
async function createBackupScenario(page: Page, names: ScenarioNames) {
await page.getByRole("button", { name: "Create Volume" }).click(); await page.getByRole("button", { name: "Create Volume" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test Volume"); await page.getByRole("textbox", { name: "Name" }).fill(names.volumeName);
await page.getByRole("button", { name: "test-data" }).click(); await page.getByRole("button", { name: "test-data" }).click();
await page.getByRole("button", { name: "Create Volume" }).click(); await page.getByRole("button", { name: "Create Volume" }).click();
await expect(page.getByText("Volume created successfully")).toBeVisible(); await expect(page.getByText("Volume created successfully")).toBeVisible();
// 2. Create a local repository on the default location
await page.getByRole("link", { name: "Repositories" }).click(); await page.getByRole("link", { name: "Repositories" }).click();
await page.getByRole("button", { name: "Create repository" }).click(); await page.getByRole("button", { name: "Create repository" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test Repo"); await page.getByRole("textbox", { name: "Name" }).fill(names.repositoryName);
await page.getByRole("combobox", { name: "Backend" }).click(); await page.getByRole("combobox", { name: "Backend" }).click();
await page.getByRole("option", { name: "Local" }).click(); await page.getByRole("option", { name: "Local" }).click();
await page.getByRole("button", { name: "Create repository" }).click(); await page.getByRole("button", { name: "Create repository" }).click();
await expect(page.getByText("Repository created successfully")).toBeVisible(); await expect(page.getByText("Repository created successfully")).toBeVisible({ timeout: 30000 });
// 3. Create a backup schedule
await page.getByRole("link", { name: "Backups" }).click(); await page.getByRole("link", { name: "Backups" }).click();
await page.getByRole("button", { name: "Create a backup job" }).click(); const createBackupButton = page.getByRole("button", { name: "Create a backup job" }).first();
if (await createBackupButton.isVisible()) {
await createBackupButton.click();
} else {
await page.getByRole("link", { name: "Create a backup job" }).first().click();
}
await page.getByRole("combobox").filter({ hasText: "Choose a volume to backup" }).click(); await page.getByRole("combobox").filter({ hasText: "Choose a volume to backup" }).click();
await page.getByRole("option", { name: "Test Volume" }).click(); await page.getByRole("option", { name: names.volumeName }).click();
await page.getByRole("textbox", { name: "Backup name" }).fill("Test Backup"); await page.getByRole("textbox", { name: "Backup name" }).fill(names.backupName);
await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click(); await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click();
await page.getByRole("option", { name: "Test Repo" }).click(); await page.getByRole("option", { name: names.repositoryName }).click();
await page.getByRole("combobox").filter({ hasText: "Select frequency" }).click(); await page.getByRole("combobox").filter({ hasText: "Select frequency" }).click();
await page.getByRole("option", { name: "Daily" }).click(); await page.getByRole("option", { name: "Daily" }).click();
await page.getByRole("textbox", { name: "Execution time" }).fill("00:00"); await page.getByRole("textbox", { name: "Execution time" }).fill("00:00");
await page.getByRole("button", { name: "Create" }).click(); await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByText("Backup job created successfully")).toBeVisible(); await expect(page.getByText("Backup job created successfully")).toBeVisible();
}
test("can backup & restore a file", async ({ page }, testInfo) => {
const runId = getRunId(testInfo);
const names = getScenarioNames(runId);
const filePath = prepareTestFile(runId);
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names);
// 4. Runs that schedule once
await page.getByRole("button", { name: "Backup now" }).click(); await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("Backup started successfully")).toBeVisible();
await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 });
// 5. Modify the json file after the backup
fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" }));
// 6. Restores the file from backup await page
await page.getByRole("button", { name: /20 B/ }).click(); .getByRole("button", { name: /\d+ B$/ })
.first()
.click();
await page.getByRole("link", { name: "Restore" }).click(); await page.getByRole("link", { name: "Restore" }).click();
await expect(page).toHaveURL(/\/restore/); await expect(page).toHaveURL(/\/restore/);
await page.getByRole("button", { name: "Restore All" }).click(); await page.getByRole("button", { name: "Restore All" }).click();
await expect(page.getByText("Restore completed")).toBeVisible({ timeout: 30000 }); await expect(page.getByText("Restore completed")).toBeVisible({ timeout: 30000 });
// 7. Ensures that the file is back to its previous state
const restoredContent = fs.readFileSync(filePath, "utf8"); const restoredContent = fs.readFileSync(filePath, "utf8");
expect(JSON.parse(restoredContent)).toEqual({ data: "test file" }); expect(JSON.parse(restoredContent)).toEqual({ data: "test file" });
}); });
test("deleting a volume cascades and removes its backup schedule", async ({ page }) => { test("deleting a volume cascades and removes its backup schedule", async ({ page }, testInfo) => {
await gotoAndWaitForAppReady(page, "/backups"); const runId = getRunId(testInfo);
await page.getByText("Test Backup", { exact: true }).first().click(); const names = getScenarioNames(runId);
const volumeLink = page.locator("main").getByRole("link", { name: "Test Volume", exact: true }).first(); await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names);
await gotoAndWaitForAppReady(page, "/backups");
await page.getByText(names.backupName, { exact: true }).first().click();
const volumeLink = page.locator("main").getByRole("link", { name: names.volumeName, exact: true }).first();
await expect(volumeLink).toBeVisible(); await expect(volumeLink).toBeVisible();
await volumeLink.click(); await volumeLink.click();
await expect(page).toHaveURL(/\/volumes\/[^/?#]+/); await expect(page).toHaveURL(/\/volumes\/[^/?#]+/);
@ -95,5 +125,5 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
await expect(page.getByText("Volume deleted successfully")).toBeVisible(); await expect(page.getByText("Volume deleted successfully")).toBeVisible();
await gotoAndWaitForAppReady(page, "/backups"); await gotoAndWaitForAppReady(page, "/backups");
await expect(page.getByText("Test Backup", { exact: true })).toHaveCount(0); await expect(page.getByText(names.backupName, { exact: true })).toHaveCount(0);
}); });

167
e2e/0003-oidc.spec.ts Normal file
View file

@ -0,0 +1,167 @@
import { expect, test, type Browser, type Page } from "@playwright/test";
import { gotoAndWaitForAppReady, waitForAppReady } from "./helpers/page";
const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557";
const issuer = `${dexOrigin}/dex`;
const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`;
const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`;
const providerIds = {
register: "test-oidc-register",
uninvited: "test-oidc-uninvited",
invited: "test-oidc-invited",
autoLink: "test-oidc",
} as const;
const dexPassword = "password";
const uninvitedUserEmail = "admin@example.com";
const invitedUserEmail = "user@example.com";
const autoLinkTargetEmail = "test@example.com";
const autoLinkTargetUsername = "sso-link-target";
const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
async function openSsoSettings(page: Page) {
await gotoAndWaitForAppReady(page, "/settings?tab=users");
await expect(page.getByText("Single Sign-On")).toBeVisible();
}
async function registerOidcProvider(page: Page, providerId: string) {
await gotoAndWaitForAppReady(page, "/settings/sso/new");
await page.getByRole("textbox", { name: "Provider ID" }).fill(providerId);
await page.getByRole("textbox", { name: "Organization Domain" }).fill("example.com");
await page.getByRole("textbox", { name: "Issuer URL" }).fill(issuer);
await page.getByRole("textbox", { name: "Discovery Endpoint" }).fill(discoveryEndpoint);
await page.getByRole("textbox", { name: "Client ID" }).fill("zerobyte-test");
await page.getByRole("textbox", { name: "Client Secret" }).fill("test-secret-12345");
await page.getByRole("button", { name: "Register Provider" }).click();
await expect(page.getByText("SSO provider registered successfully")).toBeVisible();
await expect(page.getByRole("cell", { name: providerId, exact: true })).toBeVisible();
}
async function createPendingInvitation(page: Page, email: string) {
const response = await page.request.post("/api/auth/organization/invite-member", {
headers: {
Origin: appBaseUrl,
},
data: {
email,
role: "member",
},
});
if (!response.ok()) {
throw new Error(`Failed to invite ${email}: ${await response.text()}`);
}
}
async function createAutoLinkTargetUser(page: Page, email: string, username: string) {
const response = await page.request.post("/api/auth/admin/create-user", {
headers: {
Origin: appBaseUrl,
},
data: {
email,
password: dexPassword,
name: "SSO Link Target",
role: "user",
data: {
username,
hasDownloadedResticPassword: true,
},
},
});
if (!response.ok()) {
throw new Error(`Failed to create auto-link target user: ${await response.text()}`);
}
}
async function setProviderAutoLinking(page: Page, providerId: string, enabled: boolean) {
await openSsoSettings(page);
const providerRow = page
.getByRole("row")
.filter({ has: page.getByRole("cell", { name: providerId, exact: true }) })
.first();
const autoLinkSwitch = providerRow.getByRole("switch");
const expectedState = enabled ? "true" : "false";
const currentState = await autoLinkSwitch.getAttribute("aria-checked");
const expectedToast = enabled ? "Automatic account linking enabled" : "Automatic account linking disabled";
if (currentState !== expectedState) {
await autoLinkSwitch.click();
await expect(page.getByText(expectedToast)).toBeVisible();
}
await expect(autoLinkSwitch).toHaveAttribute("aria-checked", expectedState);
}
async function withOidcLoginAttempt(
browser: Browser,
providerId: string,
dexLogin: string,
assertions: (page: Page) => Promise<void>,
) {
const context = await browser.newContext({
storageState: {
cookies: [],
origins: [],
},
});
const page = await context.newPage();
try {
await gotoAndWaitForAppReady(page, `${appBaseUrl}/login`);
await page.getByRole("button", { name: `Log in with ${providerId}`, exact: true }).click();
await page.waitForURL(/\/dex\/auth/, { timeout: 60000 });
await page.locator('input[name="login"]').fill(dexLogin);
await page.locator('input[name="password"]').fill(dexPassword);
await page.locator('button[type="submit"]').click();
await assertions(page);
} finally {
await context.close().catch(() => undefined);
}
}
test("admin can register an OIDC provider", async ({ page }) => {
await registerOidcProvider(page, providerIds.register);
});
test("uninvited OIDC users are blocked", async ({ page, browser }) => {
await registerOidcProvider(page, providerIds.uninvited);
await withOidcLoginAttempt(browser, providerIds.uninvited, uninvitedUserEmail, async (ssoPage) => {
await ssoPage.waitForURL(/\/login(\/error)?/, { timeout: 60000 });
await waitForAppReady(ssoPage);
await expect(ssoPage.getByText(inviteOnlyMessage)).toBeVisible();
});
});
test("invited OIDC users can sign in", async ({ page, browser }) => {
await registerOidcProvider(page, providerIds.invited);
await createPendingInvitation(page, invitedUserEmail);
await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => {
await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 });
await waitForAppReady(ssoPage);
await expect(ssoPage).toHaveURL(/\/volumes/);
});
});
test("auto-link setting can be enabled for an OIDC provider", async ({ page, browser }) => {
await registerOidcProvider(page, providerIds.autoLink);
await createAutoLinkTargetUser(page, autoLinkTargetEmail, autoLinkTargetUsername);
await createPendingInvitation(page, autoLinkTargetEmail);
await setProviderAutoLinking(page, providerIds.autoLink, true);
await withOidcLoginAttempt(browser, providerIds.autoLink, autoLinkTargetEmail, async (ssoPage) => {
await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 });
await waitForAppReady(ssoPage);
await expect(ssoPage).toHaveURL(/\/volumes/);
});
});

View file

@ -13,7 +13,7 @@
"tsc": "tsc --noEmit", "tsc": "tsc --noEmit",
"start:dev": "docker compose down && docker compose up --build zerobyte-dev", "start:dev": "docker compose down && docker compose up --build zerobyte-dev",
"start:prod": "docker compose down && docker compose up --build zerobyte-prod", "start:prod": "docker compose down && docker compose up --build zerobyte-prod",
"start:e2e": "docker compose down && docker compose up --build zerobyte-e2e", "start:e2e": "docker compose down && rm -f playwright/data/zerobyte.db playwright/data/cache.db playwright/data/restic.pass playwright/.auth/user.json playwright/restic.pass && docker compose up --build zerobyte-e2e",
"start:distroless": "docker compose down && docker compose up --build zerobyte-distroless", "start:distroless": "docker compose down && docker compose up --build zerobyte-distroless",
"gen:api-client": "dotenv -e .env.local -- openapi-ts", "gen:api-client": "dotenv -e .env.local -- openapi-ts",
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate", "gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",

View file

@ -5,8 +5,8 @@ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
timeout: 90000,
retries: 0, retries: 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html", reporter: "html",
use: { use: {
baseURL: `http://${process.env.SERVER_IP}:4096`, baseURL: `http://${process.env.SERVER_IP}:4096`,
@ -17,12 +17,16 @@ export default defineConfig({
{ {
name: "setup", name: "setup",
testMatch: /.*\.setup\.ts/, testMatch: /.*\.setup\.ts/,
workers: 1,
}, },
{ {
name: "chromium", name: "chromium",
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],
storageState: "playwright/.auth/user.json", storageState: "playwright/.auth/user.json",
launchOptions: {
args: ["--host-rules=MAP dex 127.0.0.1"],
},
}, },
dependencies: ["setup"], dependencies: ["setup"],
}, },

View file

@ -0,0 +1,48 @@
issuer: http://dex:5557/dex
storage:
type: sqlite3
config:
file: /tmp/dex.db
web:
http: 0.0.0.0:5557
enablePasswordDB: true
staticPasswords:
- email: "admin@example.com"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
- email: "user@example.com"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "user"
- email: "test@example.com"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "test"
- email: "test@test.com"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "test-local"
staticClients:
- id: zerobyte-test
name: Zerobyte Test
redirectURIs:
- "http://localhost:3000/api/auth/sso/callback/test-oidc"
- "http://localhost:3000/api/auth/sso/callback/test-oidc-register"
- "http://localhost:3000/api/auth/sso/callback/test-oidc-uninvited"
- "http://localhost:3000/api/auth/sso/callback/test-oidc-invited"
- "http://localhost:3000/api/auth/sso/callback/test-oidc-autolink"
- "http://localhost:4096/api/auth/sso/callback/test-oidc"
- "http://localhost:4096/api/auth/sso/callback/test-oidc-register"
- "http://localhost:4096/api/auth/sso/callback/test-oidc-uninvited"
- "http://localhost:4096/api/auth/sso/callback/test-oidc-invited"
- "http://localhost:4096/api/auth/sso/callback/test-oidc-autolink"
secret: test-secret-12345
oauth2:
skipApprovalScreen: true
logger:
level: debug
format: json