From ef62e1daf98681f91ef6c0c39635689f63a5479d Mon Sep 17 00:00:00 2001 From: jinkunsun Date: Sun, 20 Sep 2026 00:31:24 +0800 Subject: [PATCH 1/4] feat(auth): delegate hosted passwords to Supabase --- README.md | 2 + README.zh-CN.md | 2 + docs/hosted.md | 32 +++-- migrations/0004_managed_auth.sql | 10 ++ scripts/check-hosted-config.mjs | 4 +- scripts/test-hosted-browser.mjs | 104 +++++++++++----- src/hosted/account-crypto.ts | 35 +----- src/hosted/accounts.ts | 82 +++++++++---- src/hosted/auth-provider.ts | 112 +++++++++++++++++ src/hosted/types.ts | 2 +- src/hosted/ui.ts | 4 +- test/hosted-accounts.test.ts | 193 ++++++++++++++++++++---------- test/hosted-auth-provider.test.ts | 104 ++++++++++++++++ test/hosted-files.test.ts | 2 +- worker-configuration.d.ts | 5 +- wrangler.hosted.jsonc | 3 +- 16 files changed, 529 insertions(+), 167 deletions(-) create mode 100644 migrations/0004_managed_auth.sql create mode 100644 src/hosted/auth-provider.ts create mode 100644 test/hosted-auth-provider.test.ts diff --git a/README.md b/README.md index 1b3e8d9..d3a3738 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,5 @@ npm run dev # local dev — create a .dev.vars with AUTH_TOKEN= ## Hosted accounts (public beta) An optional, separate hosted deployment supports email/password accounts with recovery codes (no email delivery), private file pools, revocable device tokens and strict account/global usage limits. [Try the hosted beta](https://shotsync-hosted.defiabell.workers.dev) (100 accounts). It runs on Workers Free, but measured login CPU exceeds the nominal free budget and may be throttled under load. Deployment prerequisites and limits are in [docs/hosted.md](docs/hosted.md). Existing self-hosted and read-only demo deployments keep their current behavior. Tooling now requires Node.js 22+. + +The next managed-auth version delegates password work to Supabase Auth; deployment requires a dedicated personal project and is blocked until its configuration is supplied. See the migration and failure-recovery notes in the hosted guide. diff --git a/README.zh-CN.md b/README.zh-CN.md index e2335d6..be961e9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,3 +138,5 @@ npm run dev # 本地开发 —— 建一个含 AUTH_TOKEN=<任意串> 的 ## 托管账号版(公开试用) 可独立部署邮箱+密码注册/登录、恢复码重置密码(无需发邮件)、多用户文件隔离、设备令牌和账号/全站限额。[打开托管服务](https://shotsync-hosted.defiabell.workers.dev)。当前免费版试用,限 100 个账号;登录 CPU 实测仍超过免费版标称预算,高负载下可能受限。部署与限制见 [docs/hosted.md](docs/hosted.md)。现有自部署和只读 demo 保持原有使用方式;开发工具链需要 Node.js 22+。 + +新版认证已改为交给 Supabase Auth 处理密码,仍需配置独立个人项目后才能切换线上;迁移与故障处理见部署文档。 diff --git a/docs/hosted.md b/docs/hosted.md index 2d169c2..5091f45 100644 --- a/docs/hosted.md +++ b/docs/hosted.md @@ -1,12 +1,14 @@ # Hosted ShotSync beta -The hosted entry point (`src/hosted/index.ts`) adds email/password accounts with recovery codes and private per-account pools. It is a separate Worker, D1 database and R2 bucket. Existing personal deployments and the read-only demo retain their token-based behavior. The [hosted beta](https://shotsync-hosted.defiabell.workers.dev) is deployed on Workers Free; email delivery and a sender domain are not required. +The hosted entry point (`src/hosted/index.ts`) adds email/password accounts with recovery codes and private per-account pools. It is a separate Worker, D1 database and R2 bucket. Existing personal deployments and the read-only demo retain their token-based behavior. The existing [hosted beta](https://shotsync-hosted.defiabell.workers.dev) still uses the previous local password implementation until the managed-auth migration below is configured and deployed; email delivery and a sender domain are not required. ## What people can do Register with an email and password, complete Turnstile, and save the recovery code shown once before signing in. The email is an unverified username, not proof of mailbox ownership. Recovery requires the email, recovery code and a new password; a successful reset replaces the recovery code and invalidates all old sessions and device tokens. Save the replacement code. Losing both password and recovery code means there is no self-service recovery; no reset emails are sent. -Accounts are specific to ShotSync: using the same registration style as Yixi does not share accounts or its database. `verified_at` remains NULL for new accounts and is reserved for a future, explicit mailbox verification flow. Never link accounts across products or grant mailbox-based trust from an email string alone. Migration `0003_recovery.sql` only adds a nullable hash column; it does not mark existing accounts verified, delete users, or give legacy accounts guessed recovery codes. The obsolete mail-token table is retained for non-destructive migration compatibility, but mail endpoints and delivery code are removed. +Supabase Auth now owns password storage and verification. The Worker calls its HTTPS API only during registration, login and recovery; there is no PBKDF2/scrypt fallback. Provider access/refresh tokens are never sent to the browser or accepted as ShotSync credentials. D1 retains user IDs, opaque cookie sessions, device tokens, recovery-code hashes and quotas; R2 retains files. Ordinary file requests do not contact Supabase. + +Accounts are specific to ShotSync: using the same registration style as Yixi does not share accounts or its database. A dedicated personal Supabase project is required. Public Supabase signup must be disabled; ShotSync performs admin creation only after its own Turnstile and admission checks. Admin creation uses `email_confirm: true` only to allow password sign-in without mail; it is not evidence of mailbox ownership. IDs and server-controlled application metadata bind identities, never an email match alone. `verified_at` remains NULL for new accounts and is reserved for a future, explicit mailbox verification flow. Never link accounts across products or grant mailbox-based trust from an email string alone. Migration `0003_recovery.sql` only adds a nullable hash column; it does not mark existing accounts verified, delete users, or give legacy accounts guessed recovery codes. The obsolete mail-token table is retained for non-destructive migration compatibility, but mail endpoints and delivery code are removed. Browser sessions use Secure/HttpOnly/SameSite cookies. Mac and iOS clients use individually revocable device tokens (shown once, maximum 10, expire after 90 days). Use the hosted origin as the existing client's base URL and the device token as its bearer credential; the multipart `full`/optional `thumb` upload protocol is retained. Browser gallery JSON is specific to hosted mode. @@ -25,7 +27,7 @@ File access, deletion and sharing resolve ownership from authenticated account I | Downloads, including previews and shared links | Per account 2,000 requests / 1 GiB per UTC day; globally 20,000 / 20 GiB | | Authenticated API frequency | 120/min/account; shared reads count against owner | | API/share ingress | 120/min/IP, 600/min globally; D1-backed fixed windows | -| Password derivation | 120/min globally, concurrent 1, with expiring D1 lease | +| Provider password requests | 120/min globally; concurrent sign-in 1 with expiring D1 lease; mutations use persistent state | | Registration/recovery | Turnstile and per-address/IP/global request limits; no outbound mail | | Retention | 7 days; access denied immediately at expiry, cron deletes objects subsequently | | Share links | One active link/file, up to 24 hours or file expiry, 50 accesses; owner can revoke | @@ -44,11 +46,11 @@ Pending uploads expire after five minutes; the one-minute cron reclaims them, ex ## Deploy prerequisites -1. Node.js 22+ and a Cloudflare account with Workers, D1 and R2 enabled. The hosted config supports deployment on Workers Free without a custom CPU limit; measure authentication CPU on your deployment before opening registration. No email service or sender domain is required. +1. Node.js 22+, Cloudflare Workers/D1/R2, and a **dedicated personal Supabase Free project**. Password computation runs at Supabase, outside Worker CPU. Measure complete live routes before claiming Free-plan capacity; local or mocked tests cannot establish production CPU. No sender domain or email service is required. Supabase Free projects may pause after one week of inactivity and are limited to two active projects; it is not an uptime guarantee. 2. Dedicated Worker `shotsync-hosted`, R2 bucket `shotsync-hosted`, and D1 database `shotsync-hosted`. Never bind the personal or demo bucket. Put the returned D1 UUID into `wrangler.hosted.jsonc`. -3. Set `PUBLIC_ORIGIN` to the final HTTPS origin, and `TURNSTILE_SITE_KEY` to a widget restricted to that hostname. Store `TURNSTILE_SECRET_KEY` and `PASSWORD_PEPPER` as Worker secrets. Generate the pepper as 32 cryptographically random bytes encoded as 64 lowercase hex characters; never commit it or store it in D1. Missing or malformed pepper disables password operations. No other site's Turnstile keys are reused. -4. Configure the bucket's eight-day lifecycle and observability/billing alerts. Review registration and upload caps. Use a custom domain if stronger edge rules are needed. -5. With explicit deployment authorization: `npm run deploy:hosted`. It checks placeholders, applies the new hosted database migrations, then deploys the Worker. Do not run any personal/demo setup or seed scripts. +3. Set `PUBLIC_ORIGIN` to the final HTTPS origin, and `TURNSTILE_SITE_KEY` to a widget restricted to that hostname. Store `TURNSTILE_SECRET_KEY` and `SUPABASE_SECRET_KEY` as Worker secrets. The Supabase secret may be a modern `sb_secret_` key or legacy `service_role` JWT; never expose it to browsers or Wrangler vars. Put only the canonical `https://<20-character-project-ref>.supabase.co` origin in `vars.SUPABASE_URL`. Missing provider configuration fails closed. The old `PASSWORD_PEPPER` is no longer used; retain its secret until the rollout and rollback decision is complete. No other site's Turnstile keys are reused. +4. In the dedicated Supabase project, enable email/password, turn **Allow new users to sign up OFF**, **Confirm email OFF**, anonymous sign-ins OFF and unused external providers OFF. Admin creation still works with public signup disabled. Do not enable a Supabase CAPTCHA requirement for this server-only token flow: ShotSync verifies its own Turnstile before admin registration/recovery. Never reuse a company project or change another app’s settings. Configure the bucket's eight-day lifecycle and observability/billing alerts. Review registration and upload caps. Use a custom domain if stronger edge rules are needed. +5. With explicit deployment authorization: `npm run deploy:hosted`. It checks placeholders (including the required Supabase URL), applies the hosted database migrations, then deploys the Worker. Validate the provider settings and secret first; an incomplete configuration must not be shipped. Migration `0004_managed_auth.sql` preserves existing rows/files and marks old accounts `legacy`: old sessions stop working, and the existing recovery code is required to establish the same account ID at Supabase. No old password hash is uploaded, and local passwords are not silently used as a fallback. Recheck production user count before this migration; existing accounts need the documented recovery path. Do not run any personal/demo setup or seed scripts. 6. Test registration, saving the recovery code, login, recovery-code rotation, rejection of old credentials, and cross-device transfer. Confirm Turnstile hostname validation, cron cleanup and dashboard metrics. The checked-in config identifies the operator's dedicated hosted resources. For your own deployment, replace the origin, Turnstile site key, D1 ID and bucket with resources in your account; never copy another operator's resource IDs. The preflight rejects missing values and local origins. Run `npm run dev:hosted` only for local development. Local HTTPS is needed for browser session cookies; see the browser test for a fully isolated fixture environment. @@ -60,12 +62,24 @@ The checked-in config identifies the operator's dedicated hosted resources. For - `npx playwright install chromium && npm run test:browser`: isolated temporary local D1/R2, HTTPS browser login, upload/preview, device token access, anonymous denial, share/revoke, deletion and logout. Never contacts production or sends mail. - `npx wrangler deploy --config wrangler.hosted.jsonc --dry-run`. -To suspend new writes, set `UPLOADS_ENABLED=0` and deploy. Retain the hosted database/bucket; do not drop tables or remove user data during rollback. The original self-hosted app and demo are independent entry points. Recovery codes, session tokens and device tokens are stored as hashes; password verifiers use PBKDF2-HMAC-SHA256 (100,000 iterations, random 32-byte salt), followed by HMAC-SHA256 with the independent pepper. Only the final HMAC is stored in D1. The versioned format is `pbkdf2-sha256:v1:100000::`. This iteration count is below the OWASP PBKDF2 recommendation: it is an explicit free-tier tradeoff, not equivalent to the previous scrypt strength. A database-only leak does not include the pepper; a database plus pepper leak permits cheaper password guessing. Keep the pepper stable: replacing or losing it invalidates existing password verifiers, requiring users to reset with their recovery codes. Older scrypt verifiers require recovery-code reset; the operator confirmed zero hosted accounts before this switch. +To suspend new writes, set `UPLOADS_ENABLED=0` and deploy. Retain the hosted database/bucket; do not drop tables or remove user data during rollback. The original self-hosted app and demo are independent entry points. Recovery codes, session tokens and device tokens remain hashed. New D1 user records contain only the marker `external:supabase` in the legacy password column. Passwords are handled by Supabase over HTTPS and never persisted or logged by the Worker. Passwords require at least 10 characters and at most 72 UTF-8 bytes to avoid bcrypt truncation; the UI explains this for non-ASCII passwords. + +## Distributed mutation failures + +Registration reserves a D1 slot before calling the provider. Active users plus pending reservations cannot exceed the cap. A definite upstream rejection releases the reservation for a new attempt; a timeout, malformed response or uncertain failure retains it because a Supabase user may already exist. Reservations never expire automatically while pending. Do not reassign an identity just because its email matches. A successful response finalizes the reservation and user row in one D1 batch. + +Recovery first atomically marks the account `resetting` and increments `auth_version`. All old cookies/devices and in-flight old logins become unusable before the provider mutation. The password update carries a server-owned operation marker for diagnosis. On success, D1 rotates the recovery hash and returns the replacement code. Any uncertain outcome remains locked; the Worker never retries password mutations automatically or unlocks on a timer, because a delayed prior request could overwrite a newer password. -Official references: [D1 transactions](https://developers.cloudflare.com/d1/worker-api/d1-database/), [R2 lifecycle](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). +Operator reconciliation is required for interrupted mutations. Inspect D1 `auth_registrations` or `users.auth_operation`, then the **same UUID** at Supabase with matching `shotsync_origin`/`shotsync_user_id`; for reset, inspect `shotsync_operation`. Establish the remote request has completed before any repair. Do not delete pending rows, blindly retry with a different password, auto-link by email, or log raw provider responses. This beta deliberately favors stopping an uncertain operation over unsafe automatic repair. A lost successful response can also lose a newly displayed recovery code; users should keep their known password and contact the operator rather than expecting email recovery. + +An ordinary code-only rollback after applying migration 0004 is unsafe: old code does not honor `auth_state`, and managed password records cannot be verified locally. Keep managed auth/state checks and suspend affected operations while repairing. Preserve D1/R2 and provider users; do not delete data or roll back schema destructively. + +Official references: [Supabase Auth configuration](https://supabase.com/docs/guides/auth/general-configuration), [Supabase API keys](https://supabase.com/docs/guides/getting-started/api-keys), [Supabase Free limits](https://supabase.com/pricing), [D1 transactions](https://developers.cloudflare.com/d1/worker-api/d1-database/), [R2 lifecycle](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). Toolchain note: the compatible Vitest/Workers test stack currently reports development-only npm advisories (8 at implementation time); these packages are not imported by the deployed Worker. Run development servers on loopback only. The package resolver rejected the newest advertised Wrangler version with a publication-date cutoff; this change uses the resolved lockfile and its supported compatibility date. Track the toolchain updates separately before exposing any development server. Launch preparation (2026-09-19): dedicated D1/R2/Turnstile resources, all three migrations and eight-day object expiry are configured. Cron capacity has been freed. The password implementation supports deploying on Workers Free; deployment does not establish reliable operation within its CPU budget. Remote D1 required parenthesized CASE expressions in migration 0002 without changing quota behavior. Launch verification (2026-09-20): PR #4 deployed as `b20bde51-2865-4464-a784-1a7e3f51b370`; both secrets are installed and the one-minute cleanup cron is registered. Homepage returns 200 and invalid Turnstile registration returns 403. Five unknown-account login probes (which perform the same PBKDF2/HMAC work) returned 401 normally, with **27–46 ms CPU**, down from prior scrypt probes of 172–326 ms. This still exceeds the documented Workers Free 10 ms CPU budget: burst tolerance allowed these requests, and reliable login under load is not established. No paid upgrade was made and the KDF was not weakened further. Successful login/upload/sharing were verified in the isolated local browser; production registration/recovery remain dependent on a real Turnstile challenge. 115 automated tests, TypeScript, dry run and independent code review passed. + +Managed-auth implementation status (2026-09-20): provider adapter, persistent registration/recovery state and tests are implemented. Personal Supabase project credentials have not been supplied. `SUPABASE_URL` is deliberately empty and deployment preflight rejects it. No migration 0004 or managed-auth deployment has been performed, and no production CPU improvement is claimed for this version. Store the project URL/secret in a mode-0600 local env file outside the repo, then back it up encrypted in the personal secrets vault. diff --git a/migrations/0004_managed_auth.sql b/migrations/0004_managed_auth.sql new file mode 100644 index 0000000..6b012c4 --- /dev/null +++ b/migrations/0004_managed_auth.sql @@ -0,0 +1,10 @@ +ALTER TABLE users ADD COLUMN auth_provider_id TEXT; +ALTER TABLE users ADD COLUMN auth_state TEXT NOT NULL DEFAULT 'legacy' CHECK(auth_state IN ('legacy','active','resetting')); +ALTER TABLE users ADD COLUMN auth_operation TEXT; +CREATE UNIQUE INDEX users_auth_provider ON users(auth_provider_id); +CREATE TABLE auth_registrations ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','failed','complete')) +); diff --git a/scripts/check-hosted-config.mjs b/scripts/check-hosted-config.mjs index 8fd7b9d..1ac4980 100644 --- a/scripts/check-hosted-config.mjs +++ b/scripts/check-hosted-config.mjs @@ -5,8 +5,10 @@ try { const url=new URL(c.vars.PUBLIC_ORIGIN); if(url.protocol!=='https:' || ['localhost','127.0.0.1'].includes(url.hostname) || url.origin!==c.vars.PUBLIC_ORIGIN)problems.push('PUBLIC_ORIGIN must be the canonical production HTTPS origin'); } catch {problems.push('PUBLIC_ORIGIN is required');} +if(!/^https:\/\/[a-z0-9]{20}\.supabase\.co$/.test(c.vars.SUPABASE_URL||''))problems.push('Set SUPABASE_URL to a dedicated personal Supabase project before deployment'); +if(c.vars.SUPABASE_SECRET_KEY || c.vars.PASSWORD_PEPPER)problems.push('Secrets must not be stored in Wrangler vars'); if(!c.vars.TURNSTILE_SITE_KEY)problems.push('TURNSTILE_SITE_KEY is required'); if(!c.d1_databases?.[0]?.database_id || c.d1_databases[0].database_id==='00000000-0000-0000-0000-000000000000')problems.push('Set the dedicated hosted D1 database ID'); if(c.name!=='shotsync-hosted' || c.r2_buckets?.[0]?.bucket_name!=='shotsync-hosted')problems.push('Hosted Worker and bucket must remain separate from personal/demo instances'); if(problems.length){console.error(problems.join('\n'));process.exit(1);} -console.log('Hosted config ready. Confirm PASSWORD_PEPPER and Turnstile secrets, bucket lifecycle, and deployment authorization before publishing.'); +console.log('Hosted config ready. Confirm SUPABASE_SECRET_KEY and Turnstile secrets, disabled public provider signup, bucket lifecycle, and deployment authorization before publishing.'); diff --git a/scripts/test-hosted-browser.mjs b/scripts/test-hosted-browser.mjs index 4bf16e4..8976e7d 100644 --- a/scripts/test-hosted-browser.mjs +++ b/scripts/test-hosted-browser.mjs @@ -1,30 +1,77 @@ import { chromium, expect } from '@playwright/test'; -import { execFileSync, spawn } from 'node:child_process'; -import { pbkdf2Sync, createHmac } from 'node:crypto'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Miniflare, Response as MiniflareResponse } from 'miniflare'; +import { readD1Migrations } from '@cloudflare/vitest-pool-workers/config'; const temp = mkdtempSync(join(tmpdir(), 'shotsync-browser-')); const origin = 'https://localhost:8788'; -const cli = 'node_modules/wrangler/bin/wrangler.js'; -const common = ['--config','wrangler.hosted.jsonc','--persist-to',join(temp,'state')]; -const run = args => execFileSync(process.execPath,[cli,...args],{stdio:'pipe'}); +const providerOrigin = 'https://abcdefghijklmnopqrst.supabase.co'; +const secret = 'sb_secret_browserfixture123456789'; +const password = 'browser-fixture-password'; +const fixtureId = '11111111-2222-4333-8444-555555555555'; +const providerUsers = new Map([[fixtureId, { id: fixtureId, email: 'browser@example.com', password, + app_metadata: { shotsync_origin: origin, shotsync_user_id: fixtureId } }]]); +const providerCalls = []; +const json = (value, status = 200) => MiniflareResponse.json(value, { status }); +// Fake only the external services. Requests still traverse the bundled Worker, +// its real D1/R2 bindings, session cookies, identity validation and recovery flow. +async function outbound(request) { + const url = new URL(request.url); + if (url.origin === 'https://challenges.cloudflare.com' && url.pathname === '/turnstile/v0/siteverify') { + const body = await request.formData(); + return json({ success: body.get('secret') === 'fixture-turnstile-secret' && body.get('response') === 'fixture-challenge', hostname: 'localhost' }); + } + if (url.origin !== providerOrigin) throw new Error('Unexpected browser fixture outbound origin'); + expect(request.headers.get('apikey')).toBe(secret); + expect(request.headers.has('Authorization')).toBe(false); + providerCalls.push(`${request.method} ${url.pathname}`); + const body = request.method === 'GET' ? null : await request.json(); + const publicUser = user => ({ id: user.id, email: user.email, app_metadata: user.app_metadata }); + if (url.pathname === '/auth/v1/token' && request.method === 'POST') { + const user = [...providerUsers.values()].find(user => user.email === body.email && user.password === body.password); + return user ? json({ user: publicUser(user) }) : json({ error_code: 'invalid_credentials' }, 400); + } + if (url.pathname === '/auth/v1/admin/users' && request.method === 'POST') { + expect(body.email_confirm).toBe(true); + if ([...providerUsers.values()].some(user => user.email === body.email)) return json({ error_code: 'email_exists' }, 422); + providerUsers.set(body.id, body); + return json(publicUser(body)); + } + const id = url.pathname.match(/^\/auth\/v1\/admin\/users\/([a-f0-9-]+)$/)?.[1]; + const user = providerUsers.get(id); + if (!user) return json({ error_code: 'user_not_found' }, 404); + if (request.method === 'PUT') { + user.password = body.password; + if (body.app_metadata) user.app_metadata = { ...user.app_metadata, ...body.app_metadata }; + } + return json(publicUser(user)); +} let server, browser; try { - run(['d1','migrations','apply','shotsync-hosted','--local',...common]); - const password='browser-fixture-password'; - // Valid format salt; test account exists only in the temporary local database. - const validSalt='a'.repeat(64), pepper='b'.repeat(64); - const validHash='pbkdf2-sha256:v1:100000:'+validSalt+':'+createHmac('sha256',Buffer.from(pepper,'hex')).update(pbkdf2Sync(password,Buffer.from(validSalt,'hex'),100000,32,'sha256')).digest('hex'); - const sql=join(temp,'fixture.sql'); - writeFileSync(sql,`INSERT INTO users(id,email,password_hash,verified_at,created_at) VALUES('browser','browser@example.com','${validHash}',NULL,1);`); - run(['d1','execute','shotsync-hosted','--local','--file',sql,...common]); - server=spawn(process.execPath,[cli,'dev','--local','--ip','127.0.0.1','--local-protocol','https','--port','8788','--var','PUBLIC_ORIGIN:'+origin,'--var','TURNSTILE_SITE_KEY:','--var','PASSWORD_PEPPER:'+pepper,...common],{stdio:['ignore','pipe','pipe']}); - let output='';server.stdout.on('data',x=>output+=x);server.stderr.on('data',x=>output+=x); - await new Promise((resolve,reject)=>{const started=Date.now();const timer=setInterval(()=>{if(output.includes('Ready on')){clearInterval(timer);resolve();}else if(server.exitCode!==null||Date.now()-started>30000){clearInterval(timer);reject(new Error(output));}},100);}); + execFileSync(process.execPath, ['node_modules/wrangler/bin/wrangler.js', 'deploy', '--dry-run', '--config', 'wrangler.hosted.jsonc', '--outdir', join(temp, 'build')], { stdio: 'pipe' }); + server = new Miniflare({ + modules: true, modulesRoot: join(temp, 'build'), scriptPath: join(temp, 'build', 'index.js'), compatibilityDate: '2026-08-22', compatibilityFlags: ['nodejs_compat'], + host: '127.0.0.1', port: 8788, https: true, cf: false, + d1Databases: ['DB'], r2Buckets: ['BUCKET'], + bindings: { PUBLIC_ORIGIN: origin, SUPABASE_URL: providerOrigin, SUPABASE_SECRET_KEY: secret, + TURNSTILE_SITE_KEY: 'fixture-site-key', TURNSTILE_SECRET_KEY: 'fixture-turnstile-secret', REGISTRATION_LIMIT: '100', UPLOADS_ENABLED: '1' }, + outboundService: outbound, + }); + await server.ready; + const db = await server.getD1Database('DB'); + for (const migration of await readD1Migrations('migrations')) await db.batch(migration.queries.map(query => db.prepare(query))); + await db.prepare("INSERT INTO users(id,email,password_hash,verified_at,created_at,auth_provider_id,auth_state) VALUES(?,?,'external:supabase',NULL,1,?,'active')") + .bind(fixtureId, 'browser@example.com', fixtureId).run(); browser=await chromium.launch({headless:true}); const context=await browser.newContext({ignoreHTTPSErrors:true,viewport:{width:390,height:844}}); - const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); + const page=await context.newPage(); + // Deterministic challenge UI only; the Worker still calls and checks siteverify. + await page.route('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit', route => route.fulfill({ + contentType: 'application/javascript', body: "window.turnstile={render:(selector,options)=>{window.fixtureCaptcha=options;options.callback('fixture-challenge');return 'fixture-widget';},reset:()=>window.fixtureCaptcha.callback('fixture-challenge')};", + })); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); await page.goto(origin); await page.locator('#email').fill('browser@example.com');await page.locator('#password').fill(password);await page.locator('#auth-submit').click(); await expect(page.locator('#app')).toBeVisible(); @@ -44,25 +91,28 @@ try { await page.screenshot({path:join(temp,'mobile.png'),fullPage:true}); page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'删除',exact:true}).click();await expect(page.locator('.tile')).toHaveCount(0); await page.locator('#logout').click();await expect(page.locator('#auth')).toBeVisible();expect(await page.locator('#token-value').textContent()).toBe(''); - // UI-only recovery contracts; real registration/reset security is exercised by Workers tests. - const recovery='a'.repeat(64),replacement='b'.repeat(64); - await page.route('**/api/account/register',route=>route.fulfill({status:201,contentType:'application/json',body:JSON.stringify({ok:true,recoveryCode:recovery})})); - await page.route('**/api/account/reset-password',route=>{ - expect(route.request().postDataJSON()).toMatchObject({email:'new@example.com',recoveryCode:recovery,password:'replacement-password'}); - return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({ok:true,recoveryCode:replacement})}); - }); await page.locator('#tab-register').click();await page.locator('#email').fill('new@example.com');await page.locator('#password').fill(password);await page.locator('#auth-submit').click(); + await expect(page.locator('#recovery-result')).toBeVisible(); + const recovery=await page.locator('#recovery-value').textContent();expect(recovery).toMatch(/^[a-f0-9]{64}$/); await expect(page.locator('#recovery-value')).toHaveText(recovery);await expect(page.locator('#finish-recovery')).toBeDisabled(); expect(await page.locator('#password').inputValue()).toBe(''); await page.locator('#recovery-saved').check();await page.locator('#finish-recovery').click();await expect(page.locator('#recovery-value')).toHaveText(''); await page.locator('#forgot').click();await page.locator('#recovery-code').fill(recovery);await page.locator('#password').fill('replacement-password');await page.locator('#auth-submit').click(); + await expect(page.locator('#recovery-result')).toBeVisible(); + const replacement=await page.locator('#recovery-value').textContent();expect(replacement).toMatch(/^[a-f0-9]{64}$/);expect(replacement).not.toBe(recovery); await expect(page.locator('#recovery-value')).toHaveText(replacement);await expect(page.locator('#finish-recovery')).toBeDisabled(); expect(await page.locator('#recovery-code').inputValue()).toBe(''); await page.locator('#recovery-saved').check();await page.locator('#finish-recovery').click();await expect(page.locator('#recovery-value')).toHaveText(''); + await page.locator('#password').fill('replacement-password');await page.locator('#auth-submit').click(); + await expect(page.locator('#app')).toBeVisible(); + expect(providerCalls).toContain('POST /auth/v1/admin/users'); + expect(providerCalls.some(call=>call.startsWith('PUT /auth/v1/admin/users/'))).toBe(true); + const registered=await db.prepare('SELECT password_hash,auth_state,verified_at FROM users WHERE email=?').bind('new@example.com').first(); + expect(registered).toMatchObject({password_hash:'external:supabase',auth_state:'active',verified_at:null}); expect(await page.evaluate(()=>localStorage.length)).toBe(0);expect(errors).toEqual([]); - console.log('PASS: real browser unverified-account login, upload, private preview, device token, anonymous isolation, share/revoke, delete and logout; mocked registration/recovery UI saves and clears recovery codes'); + console.log('PASS: real browser unverified-account login, upload, private preview, device token, anonymous isolation, share/revoke, delete and logout; real registration/recovery stores external identity and rotates recovery codes, with fake provider/Turnstile only at outbound boundaries'); await privateContext.close();await context.close(); } finally { - if(browser)await browser.close();if(server)server.kill('SIGTERM'); + if(browser)await browser.close();if(server)await server.dispose(); rmSync(temp,{recursive:true,force:true}); } diff --git a/src/hosted/account-crypto.ts b/src/hosted/account-crypto.ts index d174d0b..cef0157 100644 --- a/src/hosted/account-crypto.ts +++ b/src/hosted/account-crypto.ts @@ -1,5 +1,4 @@ import { HttpError } from './http'; -import { timingSafeEqual } from 'node:crypto'; export function randomToken(): string { return Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, '0')).join(''); @@ -7,38 +6,8 @@ export function randomToken(): string { export async function tokenHash(value: string): Promise { return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))), b => b.toString(16).padStart(2, '0')).join(''); } -const HASH_PREFIX = 'pbkdf2-sha256:v1:100000'; -const HEX_32 = /^[a-f0-9]{64}$/; -function decodeHex(value: string): Uint8Array { - return Uint8Array.from(value.match(/../g)!, b => parseInt(b, 16)); -} -export function validPasswordPepper(value: unknown): value is string { - return typeof value === 'string' && HEX_32.test(value); -} -function requirePepper(pepper: string): void { - if (!validPasswordPepper(pepper)) throw new HttpError(503, 'Password authentication is temporarily unavailable'); -} -async function derive(password: string, salt: string, pepper: string): Promise { - const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']); - const derived = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: decodeHex(salt), iterations: 100_000 }, key, 256); - const pepperKey = await crypto.subtle.importKey('raw', decodeHex(pepper), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); - // Persist only the peppered verifier, never the intermediate PBKDF2 result. - return new Uint8Array(await crypto.subtle.sign('HMAC', pepperKey, derived)); -} -export async function hashPassword(password: string, pepper: string): Promise { - requirePepper(pepper); - const salt = randomToken(); - return `${HASH_PREFIX}:${salt}:${Array.from(await derive(password, salt, pepper), b => b.toString(16).padStart(2, '0')).join('')}`; -} -export async function verifyPassword(password: string, stored: string, pepper: string): Promise { - requirePepper(pepper); - const parts = stored.split(':'); - if (parts.length !== 5 || parts.slice(0, 3).join(':') !== HASH_PREFIX || !HEX_32.test(parts[3]) || !HEX_32.test(parts[4])) return false; - return timingSafeEqual(await derive(password, parts[3], pepper), decodeHex(parts[4])); -} - -// Bound expensive password work across requests and recover abandoned leases. -export async function withPasswordWork(db: D1Database, work: () => Promise): Promise { +// Limit read-only provider sign-ins. Mutations use persistent D1 state, not expiring leases. +export async function withAuthRequest(db: D1Database, work: () => Promise): Promise { const id = crypto.randomUUID(), now = Date.now(); const results = await db.batch([ db.prepare('DELETE FROM password_leases WHERE expires_at<=?').bind(now), diff --git a/src/hosted/accounts.ts b/src/hosted/accounts.ts index cc16c75..49578a4 100644 --- a/src/hosted/accounts.ts +++ b/src/hosted/accounts.ts @@ -1,11 +1,12 @@ import type { HostedEnv } from './types'; import { consumeRate } from './limits'; import { readJson } from './http'; -import { hashPassword, randomToken, tokenHash, validPasswordPepper, verifyPassword, withPasswordWork } from './account-crypto'; +import { configuredProvider, createPasswordUser, verifyProviderPassword, updateProviderPassword, ProviderMutationError } from './auth-provider'; +import { randomToken, tokenHash, withAuthRequest } from './account-crypto'; const DAY = 86_400_000; const COOKIE = '__Host-shotsync'; -interface UserRow { id: string; email: string; password_hash: string; verified_at: number | null; auth_version: number } +interface UserRow { id: string; email: string; password_hash: string; verified_at: number | null; auth_version: number; auth_provider_id: string | null; auth_state: 'legacy' | 'active' | 'resetting' } export interface Account { id: string; email: string; verified: boolean; via: 'cookie' | 'token' } function reply(body: unknown, status = 200, cookie?: string): Response { return Response.json(body, { status, headers: { 'Cache-Control': 'no-store', ...(cookie ? { 'Set-Cookie': cookie } : {}) } }); @@ -18,7 +19,7 @@ function sessionToken(request: Request): string | undefined { return request.headers.get('Cookie')?.split(';').map(x => x.trim()).find(x => x.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1); } function publicUser(user: UserRow) { return { id: user.id, email: user.email, verified: user.verified_at !== null }; } -function validPassword(value: unknown): value is string { return typeof value === 'string' && value.length >= 10 && value.length <= 128; } +function validPassword(value: unknown): value is string { return typeof value === 'string' && value.length >= 10 && new TextEncoder().encode(value).byteLength <= 72; } function normalizeEmail(value: unknown): string | null { if (typeof value !== 'string') return null; const email = value.trim().toLowerCase(); @@ -48,14 +49,16 @@ export async function authenticate(request: Request, env: HostedEnv): Promise? AND t.auth_version=u.auth_version`) + const row = await env.DB.prepare(`SELECT u.id,u.email,u.verified_at FROM ${table} t JOIN users u ON u.id=t.user_id WHERE t.hash=? AND t.expires_at>? AND t.auth_version=u.auth_version AND u.auth_state='active'`) .bind(await tokenHash(raw), Date.now()).first<{ id: string; email: string; verified_at: number | null }>(); return row ? { id: row.id, email: row.email, verified: row.verified_at !== null, via: authorization ? 'token' : 'cookie' } : null; } -export async function createUser(db: D1Database, id: string, email: string, passwordHash: string, recoveryHash: string, limit: number): Promise { - const result = await db.prepare(`INSERT INTO users(id,email,password_hash,recovery_hash,created_at) SELECT ?,?,?,?,? - WHERE (SELECT COUNT(*) FROM users) { + const result = await db.prepare(`INSERT INTO auth_registrations(id,email,created_at) SELECT ?,?,? + WHERE NOT EXISTS (SELECT 1 FROM users WHERE email=?) + AND (SELECT COUNT(*) FROM users)+(SELECT COUNT(*) FROM auth_registrations WHERE state='pending') 0; } export async function handleAccounts(request: Request, env: HostedEnv): Promise { @@ -88,7 +91,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise< if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) return fail('Device name must be 1–80 characters'); const token = randomToken(), id = crypto.randomUUID(), now = Date.now(); const inserted = await env.DB.prepare(`INSERT INTO device_tokens(id,hash,user_id,name,created_at,expires_at,auth_version) - SELECT ?,?,id,?,?,?,auth_version FROM users WHERE id=? AND EXISTS (SELECT 1 FROM sessions WHERE hash=? AND user_id=users.id AND auth_version=users.auth_version AND expires_at>?) AND (SELECT COUNT(*) FROM device_tokens WHERE user_id=? AND expires_at>? AND auth_version=users.auth_version)<10`) + SELECT ?,?,id,?,?,?,auth_version FROM users WHERE id=? AND auth_state='active' AND EXISTS (SELECT 1 FROM sessions WHERE hash=? AND user_id=users.id AND auth_version=users.auth_version AND expires_at>?) AND (SELECT COUNT(*) FROM device_tokens WHERE user_id=? AND expires_at>? AND auth_version=users.auth_version)<10`) .bind(id, await tokenHash(token), body.name.trim(), now, now + 90 * DAY, user.id, await tokenHash(sessionToken(request) || ''), now, user.id, now).run(); return inserted.meta.changes ? reply({ id, token }, 201) : fail('Maximum 10 active devices', 409); } @@ -101,7 +104,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise< return reply({ ok: true }, 200, cookie('', 0)); } if (!['register', 'login', 'reset-password'].includes(route)) return fail('Not found', 404); - if (!validPasswordPepper(env.PASSWORD_PEPPER)) return fail('Password authentication is temporarily unavailable', 503); + if (!configuredProvider(env)) return fail('Password authentication is temporarily unavailable', 503); const ip = await ipKey(request); if (await limited(env, `ip:${ip}`, 30, 600)) return fail('Try again later', 429); const body = await readJson(request); @@ -113,50 +116,77 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise< if (!validPassword(body.password)) return fail('Invalid email or password', 401); if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429); const user = await env.DB.prepare('SELECT * FROM users WHERE email=?').bind(email).first(); - // Equal-cost password work also for unknown addresses. - const valid = await withPasswordWork(env.DB, async () => user ? verifyPassword(body.password as string, user.password_hash, env.PASSWORD_PEPPER) : (await hashPassword(body.password as string, env.PASSWORD_PEPPER), false)); + // Provider identities are bound by immutable ID; matching email alone never grants access. + if (user && user.auth_state !== 'active') return fail('Account needs recovery or operator assistance', 409); + const valid = await withAuthRequest(env.DB, () => verifyProviderPassword(env, user?.auth_provider_id || '', email, body.password as string)); if (!user || !valid) return fail('Invalid email or password', 401); const token = randomToken(), now = Date.now(); - await env.DB.batch([ - env.DB.prepare('DELETE FROM sessions WHERE user_id=? AND (expires_at<=? OR auth_version<>?)').bind(user.id, now, user.auth_version), - env.DB.prepare('INSERT INTO sessions(hash,user_id,expires_at,auth_version) VALUES(?,?,?,?)').bind(await tokenHash(token), user.id, now + 30 * DAY, user.auth_version), + const results = await env.DB.batch([ + env.DB.prepare('DELETE FROM sessions WHERE user_id=? AND (expires_at<=? OR auth_version<>(SELECT auth_version FROM users WHERE id=?))').bind(user.id, now, user.id), + // Recovery may have started while the provider request was in flight. + env.DB.prepare(`INSERT INTO sessions(hash,user_id,expires_at,auth_version) + SELECT ?,id,?,auth_version FROM users WHERE id=? AND auth_state='active' AND auth_version=? AND auth_provider_id=?`) + .bind(await tokenHash(token), now + 30 * DAY, user.id, user.auth_version, user.auth_provider_id), env.DB.prepare('DELETE FROM sessions WHERE user_id=? AND hash NOT IN (SELECT hash FROM sessions WHERE user_id=? ORDER BY expires_at DESC LIMIT 20)').bind(user.id, user.id), ]); + if (!results[1].meta.changes) return fail('Account changed. Please sign in again.', 409); return reply({ user: publicUser(user) }, 200, cookie(token)); } if (!configured(env)) return fail('Account registration and recovery are temporarily unavailable', 503); if (!(await challenge(request, env, body.turnstileToken))) return fail('Please complete the security check', 403); if (await limited(env, `manage:${emailKey}`, 5, 3600) || await limited(env, `manage-ip:${ip}`, 10, 3600)) return fail('Try again later', 429); - if (!validPassword(body.password)) return fail('Password must be 10–128 characters'); + if (!validPassword(body.password)) return fail('Password must be at least 10 characters and at most 72 UTF-8 bytes'); if (route === 'reset-password') { if (typeof body.recoveryCode !== 'string' || !/^[a-f0-9]{64}$/.test(body.recoveryCode)) return fail('Invalid email or recovery code'); const hash = await tokenHash(body.recoveryCode); - const user = await env.DB.prepare('SELECT id FROM users WHERE email=? AND recovery_hash=?').bind(email, hash).first(); + const user = await env.DB.prepare('SELECT * FROM users WHERE email=? AND recovery_hash=?').bind(email, hash).first(); if (!user) return fail('Invalid email or recovery code'); + if (user.auth_state === 'resetting') return fail('Password recovery is pending. Contact the service operator.', 409); if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429); - const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string, env.PASSWORD_PEPPER)); + const operation = crypto.randomUUID(); + // Claim once before any external mutation. Never expire/retry an ambiguous remote update. + const claimed = await env.DB.prepare(`UPDATE users SET auth_state='resetting',auth_operation=?,auth_version=auth_version+1 + WHERE id=? AND recovery_hash=? AND auth_state IN ('active','legacy') RETURNING id`) + .bind(operation, user.id, hash).first(); + if (!claimed) return fail('Recovery is already in progress', 409); + const providerId = user.auth_provider_id || user.id; + if (user.auth_provider_id) await updateProviderPassword(env, providerId, email, body.password as string, operation); + else await createPasswordUser(env, providerId, email, body.password as string, operation); const recoveryCode = randomToken(); - // Compare-and-swap makes recovery one-time even when requests race. - const updated = await env.DB.prepare('UPDATE users SET password_hash=?,recovery_hash=?,auth_version=auth_version+1 WHERE email=? AND recovery_hash=? RETURNING id') - .bind(password, await tokenHash(recoveryCode), email, hash).first(); - if (!updated) return fail('Recovery code already used', 409); + const updated = await env.DB.prepare(`UPDATE users SET password_hash='external:supabase',auth_provider_id=?,auth_state='active',auth_operation=NULL,recovery_hash=? + WHERE id=? AND auth_state='resetting' AND auth_operation=? RETURNING id`) + .bind(providerId, await tokenHash(recoveryCode), user.id, operation).first(); + if (!updated) return fail('Password recovery is pending. Contact the service operator.', 503); return reply({ ok: true, recoveryCode }, 200, cookie('', 0)); } if (await limited(env, 'registrations', 200, 86400)) return fail('Try again later', 429); const cap = Math.max(1, Math.min(100, Number.parseInt(env.REGISTRATION_LIMIT || '100', 10) || 100)); if (await env.DB.prepare('SELECT id FROM users WHERE email=?').bind(email).first()) return fail('Account already exists. Sign in or use your recovery code.', 409); - if ((await env.DB.prepare('SELECT COUNT(*) n FROM users').first<{ n: number }>())!.n >= cap) return fail('Trial is full. Please try again later.', 409); if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429); - const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string, env.PASSWORD_PEPPER)); + const id = crypto.randomUUID(); + if (!(await reserveRegistration(env.DB, id, email, cap))) return fail('Trial is full or registration is already pending.', 409); + try { await createPasswordUser(env, id, email, body.password as string); } + catch (error) { + // Only a definitive rejection releases the slot. Timeout/5xx may already have created the identity. + if (error instanceof ProviderMutationError && error.definitive) { + await env.DB.prepare("UPDATE auth_registrations SET state='failed' WHERE id=? AND state='pending'").bind(id).run(); + } + throw error; + } const recoveryCode = randomToken(); - const inserted = await createUser(env.DB, crypto.randomUUID(), email, password, await tokenHash(recoveryCode), cap); - if (!inserted) return fail('Account already exists or trial is full.', 409); + await env.DB.batch([ + env.DB.prepare(`INSERT INTO users(id,email,password_hash,recovery_hash,created_at,auth_provider_id,auth_state) + SELECT id,email,'external:supabase',?,?,id,'active' FROM auth_registrations WHERE id=? AND state='pending'`) + .bind(await tokenHash(recoveryCode), Date.now(), id), + env.DB.prepare("UPDATE auth_registrations SET state='complete' WHERE id=? AND state='pending'").bind(id), + ]); return reply({ ok: true, recoveryCode }, 201); } export async function cleanupAccounts(db: D1Database): Promise { const now = Date.now(); await db.batch([ + db.prepare("DELETE FROM auth_registrations WHERE state IN ('failed','complete') AND created_at /^[a-zA-Z0-9_-]+$/.test(p)) && + JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))).role === 'service_role'; + } catch { return false; } +} +export function configuredProvider(env: ProviderEnv): boolean { + return typeof env.SUPABASE_URL === 'string' && /^https:\/\/[a-z0-9]{20}\.supabase\.co$/.test(env.SUPABASE_URL) && + typeof env.SUPABASE_SECRET_KEY === 'string' && (/^sb_secret_[a-zA-Z0-9_-]{16,}$/.test(env.SUPABASE_SECRET_KEY) || legacyKey(env.SUPABASE_SECRET_KEY)); +} +function object(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +function identity(value: unknown, env: ProviderEnv, id: string, email: string): void { + if (!object(value) || value.id !== id || value.email !== email || !object(value.app_metadata) || + value.app_metadata.shotsync_origin !== env.PUBLIC_ORIGIN || value.app_metadata.shotsync_user_id !== id) throw new HttpError(503, UNAVAILABLE); +} +async function boundedJson(response: Response): Promise { + if (!response.body) throw new HttpError(503, UNAVAILABLE); + const reader = response.body.getReader(), chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 65536) throw new HttpError(503, UNAVAILABLE); + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return JSON.parse(new TextDecoder().decode(bytes)); + } finally { await reader.cancel().catch(() => {}); } +} +async function request(env: ProviderEnv, path: string, method: string, body?: unknown, create = false): Promise<{ status: number; data: unknown }> { + if (!configuredProvider(env)) throw new HttpError(503, UNAVAILABLE); + const controller = new AbortController(); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + (async () => { + const headers: Record = { apikey: env.SUPABASE_SECRET_KEY, 'Content-Type': 'application/json' }; + // Modern secret keys are not JWTs; only legacy service_role keys use Bearer. + if (legacyKey(env.SUPABASE_SECRET_KEY)) headers.Authorization = `Bearer ${env.SUPABASE_SECRET_KEY}`; + const response = await fetch(`${env.SUPABASE_URL}/auth/v1${path}`, { + method, headers, redirect: 'manual', signal: controller.signal, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (create && [400, 401, 403, 422, 429].includes(response.status)) { + await response.body?.cancel(); + throw new ProviderMutationError(response.status === 429 ? 429 : 503, true); + } + if (response.status === 429) { await response.body?.cancel(); throw new HttpError(429, BUSY); } + if (!response.ok && response.status !== 400) { await response.body?.cancel(); throw new HttpError(503, UNAVAILABLE); } + return { status: response.status, data: await boundedJson(response) }; + })(), + new Promise((_, reject) => { + timer = setTimeout(() => { controller.abort(); reject(new HttpError(503, UNAVAILABLE)); }, 10_000); + }), + ]); + } catch (error) { + if (error instanceof HttpError) throw error; + throw new HttpError(503, UNAVAILABLE); + } finally { if (timer) clearTimeout(timer); } +} +function requireId(id: string): void { if (!UUID.test(id)) throw new HttpError(503, UNAVAILABLE); } +export async function createPasswordUser(env: ProviderEnv, id: string, email: string, password: string, operation?: string): Promise { + try { + requireId(id); + const { status, data } = await request(env, '/admin/users', 'POST', { + id, email, password, email_confirm: true, app_metadata: { shotsync_origin: env.PUBLIC_ORIGIN, shotsync_user_id: id, ...(operation ? { shotsync_operation: operation } : {}) }, + }, true); + if (status < 200 || status >= 300) throw new HttpError(503, UNAVAILABLE); + identity(data, env, id, email); + } catch (error) { + if (error instanceof ProviderMutationError) throw error; + throw new ProviderMutationError(error instanceof HttpError ? error.status : 503, false); + } +} +export async function verifyProviderPassword(env: ProviderEnv, id: string, email: string, password: string): Promise { + if (id !== '') requireId(id); + const { status, data } = await request(env, '/token?grant_type=password', 'POST', { email, password }); + if (status === 400 && object(data) && data.error_code === 'invalid_credentials') return false; + if (status !== 200 || !object(data) || !object(data.user) || typeof data.user.id !== 'string' || !UUID.test(data.user.id) || + data.user.email !== email || !object(data.user.app_metadata)) throw new HttpError(503, UNAVAILABLE); + // Unknown local accounts still do provider password work but can never link by email. + if (id === '') return false; + identity(data.user, env, id, email); + return true; +} +export async function updateProviderPassword(env: ProviderEnv, id: string, email: string, password: string, operation?: string): Promise { + requireId(id); + const path = `/admin/users/${id}`; + const before = await request(env, path, 'GET'); + if (before.status !== 200) throw new HttpError(503, UNAVAILABLE); + identity(before.data, env, id, email); + const after = await request(env, path, 'PUT', { password, ...(operation ? { app_metadata: { shotsync_operation: operation } } : {}) }); + if (after.status !== 200) throw new HttpError(503, UNAVAILABLE); + identity(after.data, env, id, email); +} diff --git a/src/hosted/types.ts b/src/hosted/types.ts index be77f31..0e5428a 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -1,2 +1,2 @@ -export type HostedEnv = HostedBindings & { TURNSTILE_SECRET_KEY: string; PASSWORD_PEPPER: string }; +export type HostedEnv = HostedBindings & { TURNSTILE_SECRET_KEY: string; SUPABASE_URL: string; SUPABASE_SECRET_KEY: string }; export interface Account { id: string; email: string; verified: boolean; via: 'cookie' | 'token' } diff --git a/src/hosted/ui.ts b/src/hosted/ui.ts index fa01467..e18fe6b 100644 --- a/src/hosted/ui.ts +++ b/src/hosted/ui.ts @@ -6,7 +6,7 @@ export function hostedHTML(env: HostedEnv): string { return `ShotSync · 随手传,随处取
ShotSync
-

随手传,随处取。

让截图和文字,在手机与电脑之间轻松流动。免费试用限 100 个账号,内容保留 7 天。

欢迎回来

邮箱仅作为登录名,不验证邮箱,也不会发送找回密码邮件。请保存注册后显示的恢复码。

10–128 个字符
+

随手传,随处取。

让截图和文字,在手机与电脑之间轻松流动。免费试用限 100 个账号,内容保留 7 天。

欢迎回来

邮箱仅作为登录名,不验证邮箱,也不会发送找回密码邮件。请保存注册后显示的恢复码。

至少 10 个字符;最多 72 个英文字符或 24 个汉字
`; } diff --git a/test/hosted-accounts.test.ts b/test/hosted-accounts.test.ts index 8e597bd..9fc2f9c 100644 --- a/test/hosted-accounts.test.ts +++ b/test/hosted-accounts.test.ts @@ -14,7 +14,13 @@ import managedSchema from '../migrations/0004_managed_auth.sql?raw'; const db = (env as unknown as { DB: D1Database }).DB; const origin = 'https://shotsync.test'; const password = 'a-long-password!'; -const providerUsers = new Map(); +const providerUsers = new Map(); +const sessions = new Map(); +function issue(id: string, email = 'person@example.com', authVersion = 0) { + const token = `jwt.${randomToken()}.signature`; + const session = {id,email,authVersion,accessToken:token,refreshToken:randomToken(),expiresAt:Date.now()+3600000,sessionId:crypto.randomUUID()}; + sessions.set(token,session); return session; +} let bindings: HostedEnv; const passwordHash = 'external:supabase'; @@ -28,31 +34,34 @@ async function call(route: string, body?: unknown, headers?: Record { for (const statement of ((schema as string) + (recoverySchema as string) + (managedSchema as string)).split(';').filter(s => s.trim())) await db.prepare(statement).run(); vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ success: true, hostname: 'shotsync.test' })); - bindings = { ...env, DB: db, PUBLIC_ORIGIN: origin, TURNSTILE_SECRET_KEY: 'test-secret', SUPABASE_URL: 'https://abcdefghijklmnopqrst.supabase.co', SUPABASE_SECRET_KEY: 'sb_secret_test-provider-key', REGISTRATION_LIMIT: '100' } as unknown as HostedEnv; - providerUsers.clear(); - vi.spyOn(provider, 'createPasswordUser').mockImplementation(async (_env, id, email, supplied) => { + bindings = { ...env, DB: db, PUBLIC_ORIGIN: origin, TURNSTILE_SECRET_KEY: 'test-secret', SUPABASE_URL: 'https://abcdefghijklmnopqrst.supabase.co', SUPABASE_SECRET_KEY: 'sb_secret_test-provider-key', SUPABASE_PUBLISHABLE_KEY: 'sb_publishable_syntheticfixture123456789', REGISTRATION_LIMIT: '100' } as unknown as HostedEnv; + providerUsers.clear(); sessions.clear(); + vi.spyOn(provider,'verifyAccessToken').mockImplementation(async (_env, token) => sessions.get(token) || null); + vi.spyOn(provider,'refreshProviderSession').mockImplementation(async (_env, token) => [...sessions.values()].find(s=>s.refreshToken===token) || null); + vi.spyOn(provider,'revokeProviderSession').mockResolvedValue(); + vi.spyOn(provider, 'createPasswordUser').mockImplementation(async (_env, id, email, supplied, _operation, authVersion = 0) => { if (providerUsers.has(email)) throw new HttpError(503, 'Provider rejected request'); - providerUsers.set(email, { id, password: supplied }); + providerUsers.set(email, { id, password: supplied, version: authVersion }); }); vi.spyOn(provider, 'verifyProviderPassword').mockImplementation(async (_env, id, email, supplied) => { const remote = providerUsers.get(email); - return !!remote && remote.id === id && remote.password === supplied; + return remote && remote.id === id && remote.password === supplied ? issue(id,email,remote.version) : null; }); - vi.spyOn(provider, 'updateProviderPassword').mockImplementation(async (_env, id, email, supplied) => { + vi.spyOn(provider, 'updateProviderPassword').mockImplementation(async (_env, id, email, supplied, _operation, authVersion = 0) => { const remote = providerUsers.get(email); if (!remote || remote.id !== id) throw new HttpError(503, 'Provider unavailable'); - remote.password = supplied; + remote.password = supplied; remote.version = authVersion; }); }); afterEach(() => vi.restoreAllMocks()); @@ -73,7 +82,7 @@ describe('hosted accounts with real D1', () => { }); it('does not link an unrelated provider identity by matching email', async () => { await seed(); - providerUsers.set('person@example.com', { id: crypto.randomUUID(), password }); + providerUsers.set('person@example.com', { id: crypto.randomUUID(), password, version: 0 }); expect((await call('login', { email: 'person@example.com', password })).status).toBe(401); }); it('provider outages never become invalid-password errors or local fallback', async () => { @@ -116,13 +125,13 @@ describe('hosted accounts with real D1', () => { const id = await seed(), recoveryCode = randomToken(); await db.prepare('UPDATE users SET recovery_hash=? WHERE id=?').bind(await tokenHash(recoveryCode), id).run(); const cookie = await login(); - const device = await (await call('devices', { name: 'old' }, { Cookie: cookie })).json() as { token: string }; + const device = await (await call('devices', { name: 'old' }, { Authorization: cookie })).json() as { token: string }; vi.mocked(provider.updateProviderPassword).mockRejectedValue(new HttpError(503, 'Provider timeout')); const body = { email: 'person@example.com', recoveryCode, password: 'new-owner-password', turnstileToken: 'captcha' }; expect((await call('reset-password', body)).status).toBe(503); - expect(await authenticate(request('me', undefined, { Cookie: cookie }), bindings)).toBeNull(); + expect(await authenticate(request('me', undefined, { Authorization: cookie }), bindings)).toBeNull(); expect(await authenticate(request('me', undefined, { Authorization: 'Bearer ' + device.token }), bindings)).toBeNull(); - expect((await call('devices', { name: 'forbidden' }, { Cookie: cookie })).status).toBe(401); + expect((await call('devices', { name: 'forbidden' }, { Authorization: cookie })).status).toBe(401); expect((await call('login', { email: 'person@example.com', password })).status).toBe(409); expect((await call('reset-password', { ...body, password: 'another-new-password' })).status).toBe(409); expect(provider.updateProviderPassword).toHaveBeenCalledOnce(); @@ -133,7 +142,7 @@ describe('hosted accounts with real D1', () => { const id = await seed(); vi.mocked(provider.verifyProviderPassword).mockImplementationOnce(async () => { await db.prepare("UPDATE users SET auth_state='resetting',auth_version=auth_version+1 WHERE id=?").bind(id).run(); - return true; + return issue(id); }); const response = await call('login', { email: 'person@example.com', password }); expect(response.status).toBe(409); @@ -147,10 +156,10 @@ describe('hosted accounts with real D1', () => { db.prepare('UPDATE users SET auth_version=auth_version+1 WHERE id=?').bind(id), db.prepare('INSERT INTO sessions(hash,user_id,expires_at,auth_version) VALUES(?,?,?,1)').bind(await tokenHash(newToken), id, Date.now() + 60000), ]); - return true; + return issue(id); }); expect((await call('login', { email: 'person@example.com', password })).status).toBe(409); - expect(await authenticate(request('me', undefined, { Cookie: '__Host-shotsync=' + newToken }), bindings)).not.toBeNull(); + expect(await db.prepare('SELECT hash FROM sessions WHERE hash=?').bind(await tokenHash(newToken)).first()).not.toBeNull(); }); it('enforces the provider UTF-8 password boundary without truncation', async () => { const over = '中'.repeat(25), valid = '中'.repeat(24); @@ -167,30 +176,30 @@ describe('hosted accounts with real D1', () => { it('device tokens are scoped, revocable, and cannot manage devices themselves', async () => { await seed(); const cookie = await login(); - const created = await call('devices', { name: 'Mac' }, { Cookie: cookie }); + const created = await call('devices', { name: 'Mac' }, { Authorization: cookie }); expect(created.status).toBe(201); const device = await created.json() as { id: string; token: string }; const user = await authenticate(request('me', undefined, { Authorization: `Bearer ${device.token}` }), bindings); expect(user?.via).toBe('token'); expect((await call('devices', undefined, { Authorization: `Bearer ${device.token}` })).status).toBe(401); - await call(`devices/${device.id}`, undefined, { Cookie: cookie }, 'DELETE'); + await call(`devices/${device.id}`, undefined, { Authorization: cookie }, 'DELETE'); expect(await authenticate(request('me', undefined, { Authorization: `Bearer ${device.token}` }), bindings)).toBeNull(); }); it('one account cannot revoke another account device', async () => { await seed(); const a = await login(); - const response = await call('devices', { name: 'Mac' }, { Cookie: a }); + const response = await call('devices', { name: 'Mac' }, { Authorization: a }); const device = await response.json() as { id: string; token: string }; await seed('other@example.com'); const b = await login('other@example.com'); - await call(`devices/${device.id}`, undefined, { Cookie: b }, 'DELETE'); + await call(`devices/${device.id}`, undefined, { Authorization: b }, 'DELETE'); expect(await authenticate(request('me', undefined, { Authorization: `Bearer ${device.token}` }), bindings)).not.toBeNull(); }); it('concurrent device creation cannot exceed ten active tokens', async () => { const id = await seed(); const cookie = await login(); for (let i = 0; i < 9; i++) await db.prepare('INSERT INTO device_tokens(id,hash,user_id,name,created_at,expires_at,auth_version) VALUES(?,?,?,?,?,?,0)').bind(crypto.randomUUID(), randomToken(), id, 'seed', Date.now(), Date.now() + 60000).run(); - const responses = await Promise.all([call('devices', { name: 'a' }, { Cookie: cookie }), call('devices', { name: 'b' }, { Cookie: cookie })]); + const responses = await Promise.all([call('devices', { name: 'a' }, { Authorization: cookie }), call('devices', { name: 'b' }, { Authorization: cookie })]); expect(responses.map(r => r.status).sort()).toEqual([201, 409]); }); it('caps concurrent provider sign-ins atomically and releases every lease', async () => { @@ -224,8 +233,8 @@ describe('hosted accounts with real D1', () => { expect(user?.recovery_hash).toBe(await tokenHash(recoveryCode)); expect(user?.recovery_hash).not.toBe(recoveryCode); const cookie = await login('new@example.com'); - expect((await authenticate(request('me', undefined, { Cookie: cookie }), bindings))?.verified).toBe(false); - expect((await call('devices', { name: 'Mac' }, { Cookie: cookie })).status).toBe(201); + expect((await authenticate(request('me', undefined, { Authorization: cookie }), bindings))?.verified).toBe(false); + expect((await call('devices', { name: 'Mac' }, { Authorization: cookie })).status).toBe(201); expect((await db.prepare('SELECT COUNT(*) n FROM account_tokens').first<{ n: number }>())!.n).toBe(0); }); it('requires configured Turnstile and the correct challenge hostname', async () => { @@ -256,7 +265,7 @@ describe('hosted accounts with real D1', () => { const id = await seed('person@example.com', false), recoveryCode = randomToken(); await db.prepare('UPDATE users SET recovery_hash=? WHERE id=?').bind(await tokenHash(recoveryCode), id).run(); const cookie = await login(); - const deviceResponse = await call('devices', { name: 'Mac' }, { Cookie: cookie }); + const deviceResponse = await call('devices', { name: 'Mac' }, { Authorization: cookie }); const { token: device } = await deviceResponse.json() as { token: string }; const reset = (code: string) => call('reset-password', { email: 'person@example.com', recoveryCode: code, password: 'new-owner-password', turnstileToken: 'captcha' }); const responses = await Promise.all([reset(recoveryCode), reset(recoveryCode)]); @@ -265,7 +274,7 @@ describe('hosted accounts with real D1', () => { const { recoveryCode: next } = await responses.find(r => r.status === 200)!.json() as { recoveryCode: string }; expect(next).not.toBe(recoveryCode); expect((await reset(recoveryCode)).status).toBe(400); - expect(await authenticate(request('me', undefined, { Cookie: cookie }), bindings)).toBeNull(); + expect(await authenticate(request('me', undefined, { Authorization: cookie }), bindings)).toBeNull(); expect(await authenticate(request('me', undefined, { Authorization: `Bearer ${device}` }), bindings)).toBeNull(); expect((await call('login', { email: 'person@example.com', password })).status).toBe(401); expect((await call('login', { email: 'person@example.com', password: 'new-owner-password' })).status).toBe(200); @@ -276,7 +285,7 @@ describe('hosted accounts with real D1', () => { await seed('person@example.com', false); for (let i = 0; i < 5; i++) expect((await call('reset-password', { email: 'person@example.com', recoveryCode: randomToken(), password, turnstileToken: 'captcha' })).status).toBe(400); expect((await call('reset-password', { email: 'person@example.com', recoveryCode: randomToken(), password, turnstileToken: 'captcha' })).status).toBe(429); - expect(await login()).toContain('__Host-shotsync='); + expect(await login()).toContain('Bearer jwt.'); }); it('cleanup retains active unverified accounts and removes expired sessions', async () => { const id = await seed('person@example.com', false); @@ -297,9 +306,9 @@ describe('hosted accounts with real D1', () => { await seed('person@example.com', false); const response = await call('login', { email: 'person@example.com', password }); expect(response.headers.get('Set-Cookie')).toMatch(/HttpOnly; Secure; SameSite=Lax/); - const cookie = response.headers.get('Set-Cookie')!.split(';')[0]; - expect((await call('logout', {}, { Cookie: cookie })).status).toBe(200); - expect(await authenticate(request('me', undefined, { Cookie: cookie }), bindings)).toBeNull(); + const cookie = 'Bearer ' + (await response.json() as {accessToken:string}).accessToken; + expect((await call('logout', {}, { Authorization: cookie })).status).toBe(200); + expect(await authenticate(request('me', undefined, { Authorization: cookie }), bindings)).toBeNull(); }); it('requires a fresh captcha for recovery and rate limits password guessing', async () => { const id = await seed('person@example.com', false), recoveryCode = randomToken(); @@ -309,6 +318,42 @@ describe('hosted accounts with real D1', () => { expect((await call('login', { email: 'person@example.com', password })).status).toBe(429); expect((await db.prepare('SELECT recovery_hash FROM users WHERE id=?').bind(id).first())?.recovery_hash).toBe(await tokenHash(recoveryCode)); }); + it('restores a provider session using only the HttpOnly refresh cookie and never stores browser tokens in D1', async () => { + await seed(); + const response = await call('login', {email:'person@example.com',password}); + const cookie = response.headers.get('Set-Cookie')!.split(';')[0]; + expect(cookie).toContain('__Host-shotsync-refresh='); + expect(await authenticate(request('me',undefined,{Cookie:cookie}),bindings)).toBeNull(); + const refreshed = await call('refresh',{}, {Cookie:cookie}); + expect(refreshed.status).toBe(200); + const body = await refreshed.json() as {accessToken:string}; + expect(await authenticate(request('me',undefined,{Authorization:`Bearer ${body.accessToken}`}),bindings)).not.toBeNull(); + expect(await db.prepare('SELECT hash FROM sessions').first()).toBeNull(); + expect((await call('refresh',{}, {Cookie:cookie,Origin:'https://evil.test'})).status).toBe(403); + await call('logout',{}, {Authorization:`Bearer ${body.accessToken}`,Cookie:cookie}); + expect((await call('refresh',{}, {Cookie:cookie})).status).toBe(409); + }); + it('blocks all JWTs of a session and retains failed provider sign-outs indefinitely', async () => { + await seed(); + const authorization=await login(); + const session=sessions.get(authorization.slice(7))!; + vi.mocked(provider.revokeProviderSession).mockRejectedValue(new HttpError(503,'Provider unavailable')); + const logout=await call('logout',{}, {Authorization:authorization}); + expect(logout.status).toBe(200); + expect(logout.headers.get('Set-Cookie')).toContain('Max-Age=0'); + const later={...session,accessToken:'jwt.later.signature',expiresAt:Date.now()+86400000}; sessions.set(later.accessToken,later); + expect(await authenticate(request('me',undefined,{Authorization:`Bearer ${later.accessToken}`}),bindings)).toBeNull(); + expect(await db.prepare('SELECT expires_at FROM revoked_auth_sessions').first()).toEqual({expires_at:Number.MAX_SAFE_INTEGER}); + await cleanupAccounts(db); + expect((await call('refresh',{}, {Cookie:`__Host-shotsync-refresh=${session.refreshToken}`})).status).toBe(409); + }); + it('rejects stale signed auth versions and legacy browser cookies', async () => { + const id=await seed(); + const jwt=issue(id); + await db.prepare('UPDATE users SET auth_version=1 WHERE id=?').bind(id).run(); + expect(await authenticate(request('me',undefined,{Authorization:`Bearer ${jwt.accessToken}`}),bindings)).toBeNull(); + expect(await authenticate(request('me',undefined,{Cookie:`__Host-shotsync=${randomToken()}`}),bindings)).toBeNull(); + }); it('removes email verification and emailed password reset endpoints', async () => { for (const route of ['verify', 'resend-verification', 'forgot-password']) expect((await call(route, {})).status).toBe(404); }); diff --git a/test/hosted-auth-provider.test.ts b/test/hosted-auth-provider.test.ts index 4795f93..198cfa1 100644 --- a/test/hosted-auth-provider.test.ts +++ b/test/hosted-auth-provider.test.ts @@ -1,9 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { configuredProvider, createPasswordUser, verifyProviderPassword, updateProviderPassword } from '../src/hosted/auth-provider'; +import { generateKeyPair, exportJWK, SignJWT } from 'jose'; +import { verifyAccessToken, refreshProviderSession, revokeProviderSession, configuredProvider, createPasswordUser, verifyProviderPassword, updateProviderPassword } from '../src/hosted/auth-provider'; -const env = { SUPABASE_URL: 'https://abcdefghijklmnopqrst.supabase.co', SUPABASE_SECRET_KEY: 'sb_secret_syntheticfixture123456789', PUBLIC_ORIGIN: 'https://shotsync.test' }; +const env = { SUPABASE_URL: 'https://abcdefghijklmnopqrst.supabase.co', SUPABASE_SECRET_KEY: 'sb_secret_syntheticfixture123456789', SUPABASE_PUBLISHABLE_KEY:'sb_publishable_syntheticfixture123456789', PUBLIC_ORIGIN: 'https://shotsync.test' }; const id = '11111111-2222-4333-8444-555555555555', email = 'person@example.com', password = 'a-long-password'; -const user = () => ({ id, email, app_metadata: { shotsync_origin: env.PUBLIC_ORIGIN, shotsync_user_id: id } }); +const user = () => ({ id, email, app_metadata: { shotsync_origin: env.PUBLIC_ORIGIN, shotsync_user_id: id, shotsync_auth_version: 0 } }); +const keys = await generateKeyPair('ES256'); +const jwk = {...await exportJWK(keys.publicKey),kid:'test',alg:'ES256'}; +async function jwt(overrides:Record={}) { return new SignJWT({session_id:'22222222-2222-4333-8444-555555555555',app_metadata:user().app_metadata,...overrides}).setProtectedHeader({alg:'ES256',kid:'test'}).setSubject(id).setAudience('authenticated').setIssuer(`${env.SUPABASE_URL}/auth/v1`).setIssuedAt().setExpirationTime('1h').sign(keys.privateKey); } +async function sessionResponse() { return {user:user(),access_token:await jwt(),refresh_token:'synthetic_refresh_token_123456'}; } +function respond(data:unknown) { vi.mocked(fetch).mockImplementation(async input=>String(input).endsWith('/jwks.json') ? Response.json({keys:[jwk]}) : Response.json(data)); } beforeEach(() => { vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json(user())); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -31,8 +37,9 @@ describe('hosted password provider', () => { expect(configuredProvider({ ...env, SUPABASE_SECRET_KEY: `${btoa('{}').replace(/=/g, '')}.${btoa(JSON.stringify({ role: 'anon' })).replace(/=/g, '')}.signature` })).toBe(false); }); it('accepts password verification only with exact provider identity and namespace', async () => { - vi.mocked(fetch).mockResolvedValue(Response.json({ user: user(), access_token: 'never-returned' })); - expect(await verifyProviderPassword(env, id, email, password)).toBe(true); + const response=await sessionResponse(); respond(response); + expect(await verifyProviderPassword(env, id, email, password)).toMatchObject({id,email,accessToken:response.access_token}); + expect(new Headers(vi.mocked(fetch).mock.calls[0][1]?.headers).get('apikey')).toBe(env.SUPABASE_PUBLISHABLE_KEY); expect(vi.mocked(fetch).mock.calls[0][0]).toBe(`${env.SUPABASE_URL}/auth/v1/token?grant_type=password`); for (const invalid of [{ ...user(), id: crypto.randomUUID() }, { ...user(), email: 'other@example.com' }, { ...user(), app_metadata: { ...user().app_metadata, shotsync_origin: 'https://other.test' } }, { ...user(), app_metadata: { ...user().app_metadata, shotsync_user_id: crypto.randomUUID() } }, { ...user(), app_metadata: null }, null]) { vi.mocked(fetch).mockResolvedValue(Response.json({ user: invalid })); @@ -41,14 +48,14 @@ describe('hosted password provider', () => { }); it('does provider password work for unknown local users without linking by email', async () => { vi.mocked(fetch).mockResolvedValue(Response.json({ user: user() })); - expect(await verifyProviderPassword(env, '', email, password)).toBe(false); + expect(await verifyProviderPassword(env, '', email, password)).toBeNull(); expect(fetch).toHaveBeenCalledOnce(); vi.mocked(fetch).mockResolvedValue(Response.json({})); await expect(verifyProviderPassword(env, '', email, password)).rejects.toMatchObject({ status: 503 }); }); it('returns false only for explicit invalid credentials and hides provider errors', async () => { vi.mocked(fetch).mockResolvedValue(Response.json({ error_code: 'invalid_credentials' }, { status: 400 })); - expect(await verifyProviderPassword(env, id, email, password)).toBe(false); + expect(await verifyProviderPassword(env, id, email, password)).toBeNull(); for (const status of [400, 401, 403, 422, 500, 503]) { vi.mocked(fetch).mockResolvedValue(Response.json({ msg: 'private upstream details' }, { status })); await expect(verifyProviderPassword(env, id, email, password)).rejects.toMatchObject({ status: 503, message: 'Password authentication is temporarily unavailable' }); @@ -68,7 +75,7 @@ describe('hosted password provider', () => { vi.mocked(fetch).mockRejectedValue(new Error('secret network details')); await expect(createPasswordUser(env, id, email, password)).rejects.toMatchObject({ definitive: false, message: 'Password authentication is temporarily unavailable' }); }); - it('checks identity before password update and sends no metadata or email mutation', async () => { + it('checks identity before password update and preserves the account binding', async () => { vi.mocked(fetch).mockResolvedValueOnce(Response.json(user())).mockResolvedValueOnce(Response.json(user())); await updateProviderPassword(env, id, email, password); expect(fetch).toHaveBeenCalledTimes(2); @@ -76,7 +83,7 @@ describe('hosted password provider', () => { const [url, init] = vi.mocked(fetch).mock.calls[1]; expect(url).toBe(`${env.SUPABASE_URL}/auth/v1/admin/users/${id}`); expect(init?.method).toBe('PUT'); - expect(JSON.parse(init!.body as string)).toEqual({ password }); + expect(JSON.parse(init!.body as string)).toEqual({ password,app_metadata:user().app_metadata }); vi.mocked(fetch).mockClear().mockResolvedValue(Response.json({ ...user(), email: 'other@example.com' })); await expect(updateProviderPassword(env, id, email, password)).rejects.toMatchObject({ status: 503 }); expect(fetch).toHaveBeenCalledOnce(); @@ -101,4 +108,33 @@ describe('hosted password provider', () => { await expect(verifyProviderPassword(env, '../other', email, password)).rejects.toMatchObject({ status: 503 }); expect(fetch).not.toHaveBeenCalled(); }); + it('verifies signed issuer/audience/namespace/version/session claims and rejects tampering', async () => { + respond({}); + expect(await verifyAccessToken(env,await jwt())).toMatchObject({id,authVersion:0}); + for (const metadata of [{...user().app_metadata,shotsync_origin:'https://other.test'},{...user().app_metadata,shotsync_auth_version:undefined},{...user().app_metadata,shotsync_auth_version:-1}]) expect(await verifyAccessToken(env,await jwt({app_metadata:metadata}))).toBeNull(); + expect(await verifyAccessToken(env,await jwt({session_id:'bad'}))).toBeNull(); + const wrong=await new SignJWT({session_id:crypto.randomUUID(),app_metadata:user().app_metadata}).setProtectedHeader({alg:'ES256',kid:'test'}).setSubject(id).setAudience('wrong').setIssuer(`${env.SUPABASE_URL}/auth/v1`).setIssuedAt().setExpirationTime('1h').sign(keys.privateKey); + expect(await verifyAccessToken(env,wrong)).toBeNull(); + const signed=await jwt(); expect(await verifyAccessToken(env,signed.slice(0,-5)+'aaaaa')).toBeNull(); + }); + it('allows expired signed JWTs only for revocation and never accepts a forged expired token', async () => { + respond({}); + const old = await new SignJWT({session_id:crypto.randomUUID(),app_metadata:user().app_metadata}).setProtectedHeader({alg:'ES256',kid:'test'}).setSubject(id).setAudience('authenticated').setIssuer(`${env.SUPABASE_URL}/auth/v1`).setIssuedAt(Math.floor(Date.now()/1000)-7200).setExpirationTime(Math.floor(Date.now()/1000)-3600).sign(keys.privateKey); + expect(await verifyAccessToken(env,old)).toBeNull(); + expect(await verifyAccessToken(env,old,true)).toMatchObject({id}); + expect(await verifyAccessToken(env,old.slice(0,-5)+'aaaaa',true)).toBeNull(); + }); + it('refreshes provider sessions and signs out using publishable key and access JWT', async () => { + respond(await sessionResponse()); + expect(await refreshProviderSession(env,'refresh_fixture')).toMatchObject({id,email}); + const first=vi.mocked(fetch).mock.calls[0]; + expect(first[0]).toContain('grant_type=refresh_token'); + expect(new Headers(first[1]?.headers).get('apikey')).toBe(env.SUPABASE_PUBLISHABLE_KEY); + vi.mocked(fetch).mockResolvedValue(new Response(null,{status:204})); + await revokeProviderSession(env,'access_fixture'); + const last=vi.mocked(fetch).mock.calls.at(-1)!; + expect(last[0]).toContain('/logout?scope=local'); + expect(new Headers(last[1]?.headers).get('Authorization')).toBe('Bearer access_fixture'); + }); + }); diff --git a/test/hosted-files.test.ts b/test/hosted-files.test.ts index 614f639..241f205 100644 --- a/test/hosted-files.test.ts +++ b/test/hosted-files.test.ts @@ -5,7 +5,7 @@ import { consumeRate, LIMITS } from '../src/hosted/limits'; import type { HostedEnv, Account } from '../src/hosted/types'; import worker from '../src/hosted/index'; const bindings = env as unknown as HostedEnv & { TEST_MIGRATIONS: D1Migration[] }; -const hosted = { ...bindings, SUPABASE_URL: 'https://abcdefghijklmnopqrst.supabase.co', SUPABASE_SECRET_KEY: 'sb_secret_test-provider-key', PUBLIC_ORIGIN: 'https://shotsync.test', UPLOADS_ENABLED: '1' }; +const hosted = { ...bindings, SUPABASE_URL: 'https://abcdefghijklmnopqrst.supabase.co', SUPABASE_SECRET_KEY: 'sb_secret_test-provider-key', SUPABASE_PUBLISHABLE_KEY: 'sb_publishable_syntheticfixture123456789', PUBLIC_ORIGIN: 'https://shotsync.test', UPLOADS_ENABLED: '1' }; const user: Account = { id: 'u1', email: 'one@example.com', verified: false, via: 'cookie' }; const other: Account = { ...user, id: 'u2', email: 'two@example.com' }; const origin = hosted.PUBLIC_ORIGIN; diff --git a/test/hosted-ui.test.ts b/test/hosted-ui.test.ts index e3ad4d8..82565f5 100644 --- a/test/hosted-ui.test.ts +++ b/test/hosted-ui.test.ts @@ -22,8 +22,12 @@ describe('hosted browser UI security and protocol', () => { expect(html).toContain("$('token-value').textContent=''"); expect(html).toContain("$('recovery-value').textContent=''"); expect(html).toContain("URL.revokeObjectURL(url)"); - expect(html).toContain("if(response.status===401){clearPrivate()"); + expect(html).toContain("if(response.status===401){if(stamp===generation){clearPrivate()"); expect(html).toContain("cache:'no-store'"); + expect(html).toContain("headers.set('Authorization','Bearer '+accessToken)"); + expect(html).toContain("if(refreshPromise)return refreshPromise"); + expect(html).toContain("const initialGeneration=generation;refreshSession()"); + expect(html).toContain("accessToken='';expiresAt=0"); expect(html).toContain('if(stamp!==generation)return'); }); From 888a1394676f59c005e2b688def684432a38a992 Mon Sep 17 00:00:00 2001 From: jinkunsun Date: Sun, 20 Sep 2026 10:45:27 +0800 Subject: [PATCH 4/4] fix(ci): retain Workers test peer dependency in lockfile --- package-lock.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/package-lock.json b/package-lock.json index 8871a7d..b495fd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,15 @@ } } }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "peer": true + }, "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/aix-ppc64": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",