{"_id":"@adaptive-ds/zitadel-login","name":"@adaptive-ds/zitadel-login","dist-tags":{"latest":"0.2.0"},"versions":{"0.2.0":{"name":"@adaptive-ds/zitadel-login","version":"0.2.0","scripts":{"dev":"bun run dev:browser","dev:browser":"vite --config client/vite.config.ts","dev:worker":"wrangler dev --config wrangler.jsonc","build":"bun run build:browser && bun run build:worker","build:browser":"vite build --config client/vite.config.ts","build:worker":"tsc -p tsconfig.worker.json","type-check":"tsc -p tsconfig.browser.json && tsc -p tsconfig.worker.json && bun run type-check:ops","type-check:ops":"tsc --ignoreConfig --noEmit --target ESNext --module ESNext --moduleResolution Bundler --strict --noUncheckedIndexedAccess --noImplicitReturns --types bun ops/zitadel/*.ts","test":"bun test --pass-with-no-tests && vitest run --config client/vitest.config.ts","ops:zitadel:otp-email":"bun run ops/zitadel/organizationOtpEmailConfigure.ts","ops:zitadel:test-client":"bun run ops/zitadel/oidcTestClientConfigure.ts","deploy":"bash ./ops/deploy.sh","release":"bash ./ops/release.sh","format":"biome format --write README.md client/index.html client src test ops/zitadel package.json tsconfig.json tsconfig.browser.json tsconfig.worker.json biome.json bunfig.toml cliff.toml wrangler.example.jsonc","format:check":"biome format README.md client/index.html client src test ops/zitadel package.json tsconfig.json tsconfig.browser.json tsconfig.worker.json biome.json bunfig.toml cliff.toml wrangler.example.jsonc"},"dependencies":{"@adaptive-ds/mdi":"0.1.1","clsx":"^2.1.1","hono":"^4.12.28","qrcode-generator":"^2.0.4","solid-js":"^1.9.12","tailwind-merge":"^3.6.0","valibot":"^1.3.1"},"devDependencies":{"@biomejs/biome":"^2.5.2","@cloudflare/workers-types":"^5.20260810.1","@solidjs/testing-library":"^0.8.10","@tailwindcss/vite":"^4.3.3","@types/bun":"latest","happy-dom":"^20.11.2","tailwindcss":"^4.3.3","typescript":"^6.0.3","vite":"^8.0.16","vite-plugin-solid":"^2.11.12","vitest":"^4.1.10","wrangler":"^4.120.1"},"prettier":{"semi":false,"printWidth":120,"trailingComma":"all"},"type":"module","private":false,"license":"MIT","homepage":"https://zitadel-login.pages.dev/demo","author":{"name":"David Siewert","url":"https://david-siewert.com/"},"funding":{"type":"individual","url":"https://github.com/sponsors/david1gp"},"engines":{"node":">=22","bun":">=1.3.0"},"description":"A native ZITADEL Login App for Cloudflare Pages and Workers. SolidJS sign-in with email OTP, password, passkeys, IdPs, and MFA, plus a safe Login V2 fallback.","keywords":["zitadel","zitadel-login","authentication","passwordless","email-otp","passkey","webauthn","mfa","oidc","login","solidjs","cloudflare-workers","cloudflare-pages","hono","bun","typescript"],"repository":{"type":"git","url":"git+https://github.com/david1gp/zitadel-login.git"},"bugs":{"url":"https://github.com/david1gp/zitadel-login/issues"},"exports":{"./package.json":"./package.json"},"publishConfig":{"access":"public","provenance":true},"gitHead":"0ba1e048b4cc1cfd707a2745a53d2451988d2f8e","_id":"@adaptive-ds/zitadel-login@0.2.0","_nodeVersion":"26.7.0","_npmVersion":"11.19.0","dist":{"integrity":"sha512-VdyWnxMS/+DK2vaU2oLNcUKm/ibC5rWmLvrQ/7nbSBlX3Xfnks7dgRxJhqN4aHCnqH5HU1mqIVrjFKBoCjq7hQ==","shasum":"e19ae5e8ca1bea3cbfe49e24f7de1afa29a45754","tarball":"https://registry.npmjs.org/@adaptive-ds/zitadel-login/-/zitadel-login-0.2.0.tgz","fileCount":495,"unpackedSize":1409089,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQC/toKpAFB02ba1/pbOyyBSGNcvfsPY0tIbvmuNP2vXtQIgDxatGBQfUcpFEwFdYqOFb8HlwXxvoaOvKmQz704MeMU="}]},"_npmUser":{"name":"david1gp","email":"david1gruppenplan@gmail.com"},"directories":{},"maintainers":[{"name":"david1gp","email":"david1gruppenplan@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/zitadel-login_0.2.0_1786854188243_0.4196617561231386"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-16T04:23:08.067Z","0.2.0":"2026-08-16T04:23:08.463Z","modified":"2026-08-16T04:23:08.720Z"},"maintainers":[{"name":"david1gp","email":"david1gruppenplan@gmail.com"}],"description":"A native ZITADEL Login App for Cloudflare Pages and Workers. SolidJS sign-in with email OTP, password, passkeys, IdPs, and MFA, plus a safe Login V2 fallback.","homepage":"https://zitadel-login.pages.dev/demo","keywords":["zitadel","zitadel-login","authentication","passwordless","email-otp","passkey","webauthn","mfa","oidc","login","solidjs","cloudflare-workers","cloudflare-pages","hono","bun","typescript"],"repository":{"type":"git","url":"git+https://github.com/david1gp/zitadel-login.git"},"author":{"name":"David Siewert","url":"https://david-siewert.com/"},"bugs":{"url":"https://github.com/david1gp/zitadel-login/issues"},"license":"MIT","readme":"# @adaptive-ds/zitadel-login\n\nA native [ZITADEL](https://zitadel.com/) Login App for people who should not have to remember another password — and for the methods they already use.\n\nZITADEL stays the identity system, session authority, OTP generator, email sender, and verifier. This project is the focused browser flow and the secure OIDC handoff: a static SolidJS page on Cloudflare Pages and a Hono Worker with no application database.\n\nThis repository is a deployable application, not a general-purpose authentication library. Native Login V2 remains the fallback for anything the custom app does not fully own.\n\nQuick Links\n\n- demo - https://zitadel-login.pages.dev/demo\n- code - https://github.com/david1gp/zitadel-login\n- npm - https://www.npmjs.com/package/@adaptive-ds/zitadel-login\n- zitadel - https://zitadel.com/\n\n## Why this exists\n\n- **Native by design.** Uses ZITADEL v2 Auth Request, User, Session, WebAuthn, IdP, and OIDC callback APIs instead of a parallel identity store.\n- **Familiar methods.** Email OTP, username/password, passkeys, and organization-enabled Google/GitHub, plus MFA continuation.\n- **Simple for users.** Pick a method, complete the challenge, and return to the application.\n- **Safe fallback.** Unknown, unenrolled, or unfinished capabilities continue through native Login V2 without consuming the authorization request.\n- **Small operational footprint.** Static Pages + a Worker. No application database.\n- **Conservative security model.** Sensitive orchestration stays in encrypted, short-lived, host-only cookies. The ZITADEL machine credential never reaches the browser.\n\n## Architecture\n\n```text\nOIDC client\n   -> ZITADEL Auth Request\n   -> Cloudflare Pages /login (SolidJS)\n   -> Cloudflare Worker (Hono)\n   -> ZITADEL v2 Session + native challenges\n   -> ZITADEL callback\n   -> original OIDC redirect URI\n```\n\nThe Pages app reads the non-secret `globalThis.ZITADEL_LOGIN_CONFIG.apiOrigin` value from `client/public/config.js`. An empty value uses the current page origin. The Worker keeps the authorization request, session tokens, and continuation state inside encrypted flow cookies, then performs the final top-level redirect itself.\n\nCanonical routes include `/login`, `/login/email-otp`, `/login/password`, `/login/passkey`, `/login/idp/:provider`, `/login/mfa`, `/password/forgot`, and `/password/reset`.\n\n## What it covers\n\nImplemented in the custom app today:\n\n- Primary email OTP, password, and passkey sign-in\n- Google and GitHub when the organization enables them\n- Recent-account selection from an encrypted cookie\n- MFA selection, optional skip when policy allows, and checks for TOTP, email OTP, enrolled SMS OTP, U2F, and passkeys\n- Enrollment for TOTP, U2F, passkeys, and email OTP\n- Required password change and standalone password recovery (recovery is off unless you enable it)\n\nStill on native Login V2, or not finished here:\n\n- Registration, email verification, external-user linking, and organization selection\n- Recovery-code checks\n- New SMS enrollment (already-enrolled SMS factors still work)\n- Anything the Worker cannot complete safely before mutating the authorization request\n\n## Eligibility and enrollment\n\nEmail OTP is a primary passwordless option, not a second-factor screen. That path is eligible only when the request resolves to exactly one active human user in the configured organization whose email is verified, matches the submitted address, matches any ZITADEL login hint, and has `AUTHENTICATION_METHOD_TYPE_OTP_EMAIL` enrolled.\n\nAnonymous first-use enrollment is intentionally not supported: ZITADEL does not provide a trusted anonymous email-OTP enrollment path. The current bootstrap policy is to pre-enroll every active, verified-email human user, **including administrators for now**. Service users are excluded. Later users should be enrolled during trusted provisioning or after authenticating with a password, passkey, or identity provider.\n\nEnrollment itself sends no email. It only adds the native OTP Email method. Sign-in then asks ZITADEL to generate, deliver, expire, throttle, and verify each code.\n\n## Fallback behavior\n\nThe Worker redirects to Login V2 without consuming the authorization request when a user is unknown, ambiguous, inactive, unverified, outside the organization, unenrolled, or otherwise cannot complete the owned flow. Account creation and account-selection prompts that the custom app does not own use the same fallback. A `PROMPT_NONE` request never starts an interactive flow; it completes through the ZITADEL callback with `login_required`.\n\nThe fallback URL must use the configured ZITADEL origin and normally points to `/ui/v2/login`. The browser receives only relative continuation paths. Callback URLs and authorization state stay Worker-controlled.\n\n## Security model\n\n- `ZITADEL_LOGIN_CLIENT_PAT` is an encrypted Cloudflare Worker secret. It is never sent to or bundled for the browser.\n- `FLOW_COOKIE_KEY` protects a `__Host-` cookie with AES-GCM. The cookie is `Secure`, `HttpOnly`, `SameSite=Lax`, host-only, and expires with the configured flow lifetime.\n- Recent-account and password-recovery cookies are separate encrypted host-only cookies when those features are enabled.\n- CSRF tokens, strict origin checks, validated OIDC client and organization scopes, and safe callback-URL validation protect the browser-to-Worker handoff.\n- Cloudflare Rate Limit is mandatory. Rate-limit keys are HMAC-derived and opaque; raw email addresses are never used as identifiers.\n- Responses are `no-store` and include restrictive browser security headers. The Worker returns safe error messages rather than ZITADEL response bodies.\n- No password, OTP, session token, callback URL, or raw email is persisted by this project outside short-lived encrypted cookies and ZITADEL itself.\n\nThis is not a substitute for a security review, correct ZITADEL permissions, TLS, SMTP security, or Cloudflare account hardening.\n\n## Configuration\n\nCopy [`wrangler.example.jsonc`](./wrangler.example.jsonc) to `wrangler.jsonc` and keep the local file uncommitted. Use [`.dev.vars.example`](./.dev.vars.example) as the local Worker secret/value reference. `.env.example` is a generic reference; Wrangler local development reads `.dev.vars`.\n\n| Binding | Secret | Required value |\n| --- | --- | --- |\n| `ZITADEL_ORIGIN` | No | HTTPS ZITADEL origin, without a path. `http://localhost` is allowed for local development. |\n| `ZITADEL_ORGANIZATION_ID` | No | Organization containing the Login App and eligible users. |\n| `ZITADEL_ALLOWED_CLIENT_IDS` | No | Comma-separated allowlist of OIDC client IDs. |\n| `LOGIN_V2_FALLBACK_URL` | No | ZITADEL Login V2 URL on the same origin as `ZITADEL_ORIGIN`, normally `/ui/v2/login`. |\n| `PAGES_ORIGIN` | No | Exact HTTPS Pages origin allowed to call the Worker. `http://localhost` is allowed locally. |\n| `SESSION_LIFETIME_SECONDS` | No | Flow/session lifetime from `60` through `1800` seconds; the example uses `900`. |\n| `ZITADEL_CUSTOM_LOGIN_ENABLED` | No | Strict `true`/`false` emergency switch for the custom Login App; defaults to `false`. |\n| `ZITADEL_PASSWORD_RESET_V2_ENABLED` | No | Strict `true`/`false` switch for standalone password recovery; defaults to `false`. |\n| `TERMS_OF_SERVICE_URL` | No | HTTPS URL sent to the browser for the Terms of Service link. Optional. |\n| `PRIVACY_POLICY_URL` | No | HTTPS URL sent to the browser for the Privacy Policy link. Optional. |\n| `ZITADEL_LOGIN_CLIENT_PAT` | Yes | ZITADEL machine-user PAT with the permissions required by the listed v2 APIs. Set as a Worker secret. |\n| `FLOW_COOKIE_KEY` | Yes | 32 random bytes as unpadded base64url, exactly 43 characters. Set as a Worker secret. |\n| `RECENT_ACCOUNT_COOKIE_KEY` | Optional | Same key format as `FLOW_COOKIE_KEY`. Set this to remember recent accounts. |\n| `RECENT_ACCOUNT_COOKIE_PREVIOUS_KEY` | Optional | Previous recent-account key, used only for rotation. |\n| `RATE_LIMITER` | No | Cloudflare Rate Limit binding named `RATE_LIMITER`; the example uses a five-request, 60-second window. |\n| `EMAIL_OTP_COOLDOWN` | No | SQLite-backed Durable Object binding used for atomic email OTP cooldown reservations, including the isolated synthetic test scope. |\n| `OTP_LIMIT_TEST_SECRET` | Optional | 32–256 character Worker secret. Required together with `EMAIL_OTP_COOLDOWN` to enable `POST /api/v2/internal/otp-limit-test`. |\n\nGenerate a cookie key without putting it in shell history:\n\n```bash\nopenssl rand -base64 32 | tr '+/' '-_' | tr -d '='\n```\n\nSet deployed secrets interactively so they do not appear in command arguments:\n\n```bash\nbunx wrangler secret put ZITADEL_LOGIN_CLIENT_PAT --config wrangler.jsonc\nbunx wrangler secret put FLOW_COOKIE_KEY --config wrangler.jsonc\nbunx wrangler secret put OTP_LIMIT_TEST_SECRET --config wrangler.jsonc\n```\n\nThe isolated OTP limit test API is disabled until both `OTP_LIMIT_TEST_SECRET` and `EMAIL_OTP_COOLDOWN` are configured. It never calls ZITADEL or sends email. The request host and `Origin` header must both equal `PAGES_ORIGIN`:\n\n```bash\ncurl -X POST \"$PAGES_ORIGIN/api/v2/internal/otp-limit-test\" \\\n  -H \"Origin: $PAGES_ORIGIN\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer $OTP_LIMIT_TEST_SECRET\" \\\n  -d '{\"bucket\":\"synthetic\",\"key\":\"probe-1\"}'\n```\n\nA first accepted probe returns `200` with cooldown metadata. A repeat probe for the same key returns `429` with `Retry-After`. Unconfigured secret or Durable Object bindings return `404`.\n\n## Local development\n\nRequirements: Bun 1.3+, Node.js 22+ for the current Wrangler toolchain, a ZITADEL development organization, and a test machine credential. Do not use production credentials in local files.\n\n```bash\nbun install\ncp wrangler.example.jsonc wrangler.jsonc\ncp .dev.vars.example .dev.vars\n```\n\nReplace the placeholders in `.dev.vars` and add local-only secret values. The Vite dev server proxies `/api` to the local Worker, so run both processes:\n\n```bash\nbun run dev:worker  # http://localhost:8787\nbun run dev         # http://localhost:5173\n```\n\nFor a separately deployed Worker, set its HTTPS origin in `client/public/config.js` as `apiOrigin` before building the Pages application. The empty default is correct only when the Worker is reachable at the same origin as the page.\n\n## Testing and builds\n\n```bash\nbun run format:check\nbun run type-check\nbun run test\nbun run build\n```\n\nTests use mocked ZITADEL responses and cover the browser/Worker contract, encrypted state rotation, callbacks, fallback, origin checks, malformed state, safe errors, and opaque rate-limit keys. They do not contact a live ZITADEL instance.\n\n## Cloudflare deployment\n\nThe deploy script builds both artifacts, deploys the Worker from `wrangler.jsonc`, and uploads `dist/client` to the Pages project. It does not create projects, configure domains, or set secrets.\n\n```bash\nbunx wrangler login\ncp wrangler.example.jsonc wrangler.jsonc\n# Edit wrangler.jsonc and set the two required secrets first.\nbun run deploy\n```\n\nUse `PAGES_PROJECT_NAME` to select a different Pages project and `WRANGLER_CONFIG` to select a different local Worker config:\n\n```bash\nPAGES_PROJECT_NAME=my-login WRANGLER_CONFIG=wrangler.jsonc bun run deploy\n```\n\nBefore the build, set `client/public/config.js` to the deployed Worker origin when Pages and Worker use different origins. Keep `PAGES_ORIGIN` equal to the public Pages URL. A same-origin custom-domain setup can leave `apiOrigin` empty.\n\n## ZITADEL setup\n\nThe following assumes the deployed ZITADEL/Login V2 API version is v4.16.0.\n\n1. Create or select the organization and OIDC Login App. Record its client ID and put it in `ZITADEL_ALLOWED_CLIENT_IDS`.\n2. Point the application Login V2 base URI at the Pages `/login` route. Keep the original OIDC redirect URIs on the client application.\n3. Keep Login V2 enabled at `/ui/v2/login`. It is the fallback and rollback path.\n4. Create a least-privileged machine user/PAT for the Worker. It must read the allowed Auth Request and user/authentication-method state, create and update v2 Sessions, and complete or reject the OIDC Auth Request. Store the PAT only as `ZITADEL_LOGIN_CLIENT_PAT`.\n5. Pre-enroll the current eligible population with native OTP Email, including administrators under the current policy. Do not enroll service users. Confirm active state and verified email before each administrative enrollment.\n6. Configure SMTP in ZITADEL and set the native `VerifyEmailOTP` message fields. Start from [`ops/zitadel/message-texts/VerifyEmailOTP.v1.en.json`](./ops/zitadel/message-texts/VerifyEmailOTP.v1.en.json) and the German variant. Keep the `{{.OTP}}` placeholder. These files provide message fields only; ZITADEL keeps its native HTML shell and SMTP delivery.\n7. First live-test with an enrolled, non-privileged user. Confirm unknown and unenrolled users reach Login V2 and that `PROMPT_NONE` returns `login_required`.\n\nThe committed message artifacts prefer the existing `auth@contentoren.de` sender where available, with `it@contentoren.de` as the fallback from the original project setup. Sender identity and SMTP credentials are configured in ZITADEL, not this repository.\n\nThe project-owned bootstrap command validates the exact organization ID/name pair and active SMTP sender, reconciles both committed message-text artifacts, and paginates all active human users in that organization before adding native OTP Email only to verified-email users that do not already have it. It excludes machine users at the ZITADEL query, includes administrators, emits aggregate/redacted JSON only, and defaults to dry-run. Provide `ZITADEL_ORIGIN`, `ZITADEL_ORGANIZATION_ID`, `ZITADEL_ORGANIZATION_NAME`, `ZITADEL_ADMIN_PAT`, and an exact eligible canary address as `ZITADEL_OTP_CONFIRM_EMAIL` through a private environment source:\n\n```bash\nbun run ops:zitadel:otp-email\nZITADEL_OTP_MODE=apply bun run ops:zitadel:otp-email\n```\n\n`ops:zitadel:test-client` validates the Login V2 routing prerequisite and reconciles the dedicated public Authorization Code + S256 PKCE test client. It also defaults to dry-run; use `ZITADEL_E2E_MODE=apply` only through a private environment source when creating the absent project or client. It does not run authorization or mailbox tests.\n\n## Scripts\n\n| Script | Purpose |\n| --- | --- |\n| `bun run dev` | Start Vite/SolidJS browser development. |\n| `bun run dev:browser` | Start Vite directly. |\n| `bun run dev:worker` | Start Wrangler Worker development using `wrangler.jsonc`. |\n| `bun run build` | Build the Pages browser bundle and type-check the Worker. |\n| `bun run build:browser` | Build `dist/client`. |\n| `bun run build:worker` | Type-check the Worker project. |\n| `bun run type-check` | Type-check browser and Worker projects. |\n| `bun run test` | Run Bun tests. |\n| `bun run format` / `bun run format:check` | Format or check source and project metadata. |\n| `bun run ops:zitadel:otp-email` | Dry-run native OTP Email message/enrollment reconciliation; `ZITADEL_OTP_MODE=apply` permits changes. |\n| `bun run ops:zitadel:test-client` | Dry-run dedicated public OIDC test-client reconciliation; `ZITADEL_E2E_MODE=apply` permits changes. |\n| `bun run deploy` | Build and deploy Worker plus Pages; requires local Wrangler config and Cloudflare auth. |\n| `bun run release` | Generate a changelog, version, commit, tag, push, and GitHub release. Requires `git-cliff`, `jq`, `gh`, and authenticated Git/GitHub access. |\n\n`release` is intentionally a mutation script. Review its output and run it only from a clean, correctly configured repository; it is not part of local verification.\n\n## Limitations\n\n- Email OTP only works for users already enrolled with native ZITADEL OTP Email.\n- There is no anonymous enrollment or independent identity database here.\n- Eligibility currently targets one configured ZITADEL organization and exact, verified email matches. Administrators are included in the initial pre-enrollment policy.\n- Registration, email verification, account linking, organization selection, and recovery-code checks are not owned by the custom app yet.\n- Email delivery and message rendering are controlled by ZITADEL and its SMTP configuration. The supplied artifacts cannot replace ZITADEL's complete email HTML template.\n- A Cloudflare Rate Limit binding and encrypted Worker secrets are required for a meaningful deployment.\n- The browser is a static SPA and the Worker is a separate origin unless routing or a custom domain makes them same-origin.\n- Automated tests are mocked contract tests. Live ZITADEL enrollment, deployment, SMTP delivery, and end-to-end OIDC verification remain environment-specific.\n- This project has not been independently audited.\n\n## License\n\nMIT © [David Siewert](https://david-siewert.com/)\n","readmeFilename":"README.md","_rev":"1-7e7fbbd50ed0cb4d6a58c9734e5c4e17"}