diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index cdf84137..88521ce4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,12 +46,14 @@ jobs: mkdir -p data mkdir -p playwright/temp mkdir -p playwright/data + mkdir -p playwright/tinyauth/app-data + mkdir -p playwright/tinyauth/caddy-data chmod -R 777 playwright/temp touch .env.local echo "SERVER_IP=localhost" >> .env.local echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local echo "BASE_URL=http://localhost:4096" >> .env.local - echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local + echo "E2E_TINYAUTH_ORIGIN=https://tinyauth.example.com:5557" >> .env.local echo "TZ=Europe/Zurich" >> .env.local - name: Start zerobyte-e2e service @@ -68,13 +70,13 @@ jobs: run: | 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 + - name: Wait for Tinyauth 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' + timeout 30s bash -c 'until curl -skf https://localhost:5557/.well-known/openid-configuration; do echo "Waiting for Tinyauth..." && sleep 2; done' - name: Print docker logs if failed to start if: failure() - run: docker compose logs zerobyte-e2e dex || true + run: docker compose logs zerobyte-e2e tinyauth tinyauth-app || true - name: Make playwright directory writable run: sudo chmod 777 playwright/data/zerobyte.db @@ -85,9 +87,9 @@ jobs: - name: Dump container logs in playwright-report folder if: always() run: | - tree playwright + tree playwright || true docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true - docker compose logs dex > playwright-report/dex-logs.txt || true + docker compose logs tinyauth tinyauth-app > playwright-report/tinyauth-logs.txt || true - name: Debug - print content of /test-data in container if: failure() diff --git a/.gitignore b/.gitignore index 809f71d2..e0067a38 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ node_modules/ /playwright/.cache/ /playwright/.auth/ /playwright/*.pass +/playwright/tinyauth/app-data/ +/playwright/tinyauth/caddy-data/ playwright/.auth playwright/temp diff --git a/docker-compose.yml b/docker-compose.yml index eada0b84..32f2deda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,7 +61,9 @@ services: - DISABLE_RATE_LIMITING=true - APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b - BASE_URL=http://localhost:4096 - - TRUSTED_ORIGINS=http://dex:5557,http://localhost:5557 + - TRUSTED_ORIGINS=https://tinyauth.example.com:5557,https://localhost:5557 + - NODE_EXTRA_CA_CERTS=/tinyauth-ca/caddy/pki/authorities/local/root.crt + - SSL_CERT_FILE=/tinyauth-ca/caddy/pki/authorities/local/root.crt devices: - /dev/fuse:/dev/fuse cap_add: @@ -69,17 +71,56 @@ services: ports: - "4096:4096" depends_on: - - dex + tinyauth: + condition: service_healthy volumes: - /etc/localtime:/etc/localtime:ro - ./playwright/data:/var/lib/zerobyte/data - ./playwright/temp:/test-data + - ./playwright/tinyauth/caddy-data:/tinyauth-ca:ro - dex: - image: ghcr.io/dexidp/dex:latest + tinyauth-app: + image: ghcr.io/steveiliop56/tinyauth:v5 restart: unless-stopped + environment: + - TINYAUTH_APPURL=https://tinyauth.example.com:5557 + - TINYAUTH_SERVER_PORT=3000 + - TINYAUTH_SERVER_ADDRESS=0.0.0.0 + - TINYAUTH_DATABASE_PATH=/data/tinyauth.db + - TINYAUTH_AUTH_USERSFILE=/config/users.txt + - TINYAUTH_OIDC_PRIVATEKEYPATH=/data/tinyauth_oidc_key + - TINYAUTH_OIDC_PUBLICKEYPATH=/data/tinyauth_oidc_key.pub + - TINYAUTH_OIDC_CLIENTS_ZEROBYTE_CLIENTID=zerobyte-test + - TINYAUTH_OIDC_CLIENTS_ZEROBYTE_CLIENTSECRET=test-secret-12345 + - TINYAUTH_OIDC_CLIENTS_ZEROBYTE_TRUSTEDREDIRECTURIS=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 + - TINYAUTH_OIDC_CLIENTS_ZEROBYTE_NAME=Zerobyte Test + - TINYAUTH_LOG_LEVEL=debug + - TINYAUTH_LOG_JSON=true + volumes: + - ./playwright/tinyauth/users.txt:/config/users.txt:ro + - ./playwright/tinyauth/app-data:/data + + tinyauth: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - tinyauth-app ports: - "5557:5557" volumes: - - ./playwright/dex-config.yaml:/etc/dex/cfg.yaml:ro - command: dex serve /etc/dex/cfg.yaml + - ./playwright/tinyauth/caddy.json:/etc/caddy/caddy.json:ro + - ./playwright/tinyauth/caddy-data:/data + command: caddy run --config /etc/caddy/caddy.json + healthcheck: + test: + [ + "CMD-SHELL", + "test -f /data/caddy/pki/authorities/local/root.crt && wget -qO- --no-check-certificate https://localhost:5557/.well-known/openid-configuration >/dev/null", + ] + interval: 1s + timeout: 5s + retries: 30 + networks: + default: + aliases: + - tinyauth.example.com diff --git a/e2e/0003-oidc.spec.ts b/e2e/0003-oidc.spec.ts index 3783483a..5db12591 100644 --- a/e2e/0003-oidc.spec.ts +++ b/e2e/0003-oidc.spec.ts @@ -4,10 +4,11 @@ import { expect, test } from "./test"; import { trackBrowserErrors } from "./helpers/browser-errors"; import { gotoAndWaitForAppReady, waitForAppReady } from "./helpers/page"; -const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557"; -const issuer = `${dexOrigin}/dex`; +const tinyauthOrigin = process.env.E2E_TINYAUTH_ORIGIN ?? "https://tinyauth.example.com:5557"; +const issuer = tinyauthOrigin; const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`; const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`; +const appOrigin = new URL(appBaseUrl).origin; const setupAuthFile = path.join(process.cwd(), "playwright", ".auth", "user.json"); const providerIds = { @@ -17,7 +18,7 @@ const providerIds = { autoLink: "test-oidc-autolink", } as const; -const dexPassword = "password"; +const tinyauthPassword = "password"; const uninvitedUserEmail = "admin@example.com"; const invitedUserEmail = "user@example.com"; const autoLinkUninvitedLocalEmail = "linkguard@example.com"; @@ -49,6 +50,8 @@ type SsoSignInResponse = { url?: string; }; +type OidcUiState = "app" | "authorize" | "loading" | "login"; + async function openSsoSettings(page: Page) { await gotoAndWaitForAppReady(page, "/settings?tab=organization"); await expect(page.getByText("Single Sign-On")).toBeVisible(); @@ -98,7 +101,7 @@ async function createLocalUser(browser: Browser, email: string, username: string }, data: { email, - password: dexPassword, + password: tinyauthPassword, name: "SSO Link Target", role: "user", data: { @@ -206,10 +209,45 @@ async function startSsoLogin(page: Page, providerId: string) { return body.url; } +async function getOidcUiState(page: Page) { + const currentUrl = new URL(page.url()); + + if (currentUrl.origin === appOrigin) { + return "app" satisfies OidcUiState; + } + + const tinyauthLoginInput = page.locator('input[name="username"]'); + if (await tinyauthLoginInput.isVisible().catch(() => false)) { + return "login" satisfies OidcUiState; + } + + const authorizeButton = page.getByRole("button", { name: /authorize|allow/i }); + if (await authorizeButton.isVisible().catch(() => false)) { + return "authorize" satisfies OidcUiState; + } + + return "loading" satisfies OidcUiState; +} + +async function waitForOidcUiState(page: Page, expectedStates: OidcUiState[], timeout = 15000) { + const expectedPattern = new RegExp(`^(?:${expectedStates.join("|")})$`); + + await expect + .poll( + async () => { + return getOidcUiState(page); + }, + { timeout }, + ) + .toMatch(expectedPattern); + + return getOidcUiState(page); +} + async function withOidcLoginAttempt( browser: Browser, providerId: string, - dexLogin: string, + tinyauthLogin: string, assertions: (page: Page) => Promise, ) { const context = await browser.newContext({ @@ -229,13 +267,19 @@ async function withOidcLoginAttempt( const ssoUrl = await startSsoLogin(page, providerId); await page.goto(ssoUrl); - const dexLoginInput = page.locator('input[name="login"]'); - const dexLoginIsVisible = await dexLoginInput.isVisible({ timeout: 5000 }).catch(() => false); + let oidcUiState = await waitForOidcUiState(page, ["app", "authorize", "login"]); - if (dexLoginIsVisible) { - await dexLoginInput.fill(dexLogin); - await page.locator('input[name="password"]').fill(dexPassword); + if (oidcUiState === "login") { + const tinyauthLoginInput = page.locator('input[name="username"]'); + await tinyauthLoginInput.fill(tinyauthLogin); + await page.locator('input[name="password"]').fill(tinyauthPassword); await page.locator('button[type="submit"]').click(); + + oidcUiState = await waitForOidcUiState(page, ["app", "authorize"]); + } + + if (oidcUiState === "authorize") { + await page.getByRole("button", { name: /authorize|allow/i }).click(); } await assertions(page); @@ -246,12 +290,13 @@ async function withOidcLoginAttempt( } function isLoginPath(url: string): boolean { - const pathname = new URL(url).pathname; - return pathname === "/login" || pathname === "/login/error"; + const parsedUrl = new URL(url); + return parsedUrl.origin === appOrigin && (parsedUrl.pathname === "/login" || parsedUrl.pathname === "/login/error"); } function isSsoCallbackPath(url: string): boolean { - return new URL(url).pathname.startsWith("/api/auth/sso/callback/"); + const parsedUrl = new URL(url); + return parsedUrl.origin === appOrigin && parsedUrl.pathname.startsWith("/api/auth/sso/callback/"); } async function expectInviteOnlyLoginError(page: Page) { diff --git a/package.json b/package.json index c48319a8..f17d48f0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "tsc": "tsc --noEmit && turbo run tsc", "start:dev": "docker compose down && docker compose up --build zerobyte-dev", "start:prod": "docker compose down && docker compose up --build zerobyte-prod", - "start:e2e": "docker compose down && rm -rf playwright/data/* playwright/.auth/user.json playwright/restic.pass && mkdir -p playwright/temp && rm -rf playwright/temp/* && docker compose up --build zerobyte-e2e", + "start:e2e": "docker compose down && mkdir -p playwright/data playwright/temp playwright/tinyauth/app-data playwright/tinyauth/caddy-data && rm -rf playwright/data/* playwright/.auth/user.json playwright/restic.pass playwright/temp/* playwright/tinyauth/app-data/* && docker compose up --build zerobyte-e2e", "start:distroless": "docker compose down && docker compose up --build zerobyte-distroless", "gen:api-client": "dotenv -e .env.local -- openapi-ts", "gen:migrations": "dotenv -e .env.local -- drizzle-kit generate", diff --git a/playwright.config.ts b/playwright.config.ts index 1e488574..8aa5dcfd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ baseURL: `http://${process.env.SERVER_IP}:4096`, video: "retain-on-failure", trace: "retain-on-failure", + ignoreHTTPSErrors: true, }, projects: [ { @@ -24,7 +25,7 @@ export default defineConfig({ use: { ...devices["Desktop Chrome"], launchOptions: { - args: ["--host-rules=MAP dex 127.0.0.1"], + args: ["--host-rules=MAP tinyauth.example.com 127.0.0.1"], }, }, dependencies: ["setup"], diff --git a/playwright/dex-config.yaml b/playwright/dex-config.yaml deleted file mode 100644 index 722148bf..00000000 --- a/playwright/dex-config.yaml +++ /dev/null @@ -1,58 +0,0 @@ -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" - userID: "001" - - email: "user@example.com" - hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" - username: "user" - userID: "002" - - email: "test@example.com" - hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" - username: "test" - userID: "003" - - email: "test@test.com" - hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" - username: "test-local" - userID: "004" - - email: "linkguard@example.com" - hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" - username: "linkguard" - userID: "005" - -staticClients: - - id: zerobyte-test - name: Zerobyte Test - redirectURIs: - - "http://localhost:3000/api/auth/sso/callback/credential" - - "http://localhost:3000/api/auth/sso/callback/dex" - - "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 - -# Issuer URL: http://dex:5557/dex -# Discovery URL: http://dex:5557/dex/.well-known/openid-configuration -# Client ID: zerobyte-test -# Client Secret: test-secret-12345 diff --git a/playwright/tinyauth/caddy.json b/playwright/tinyauth/caddy.json new file mode 100644 index 00000000..8792c8b8 --- /dev/null +++ b/playwright/tinyauth/caddy.json @@ -0,0 +1,45 @@ +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [":5557"], + "routes": [ + { + "match": [ + { + "host": ["tinyauth.example.com", "localhost"] + } + ], + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [ + { + "dial": "tinyauth-app:3000" + } + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "subjects": ["tinyauth.example.com", "localhost"], + "issuers": [ + { + "module": "internal" + } + ] + } + ] + } + } + } +} diff --git a/playwright/tinyauth/users.txt b/playwright/tinyauth/users.txt new file mode 100644 index 00000000..289890e0 --- /dev/null +++ b/playwright/tinyauth/users.txt @@ -0,0 +1,5 @@ +admin@example.com:$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W +user@example.com:$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W +test@example.com:$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W +test@test.com:$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W +linkguard@example.com:$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W