diff --git a/README.md b/README.md index da24bec..0469e35 100644 --- a/README.md +++ b/README.md @@ -164,3 +164,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+. + +Hosted authentication delegates passwords and browser sessions to Supabase Auth. Operators need a dedicated personal project; website registration uses server-side admission checks while public Supabase signup stays disabled. See the migration and failure-recovery notes in the hosted guide. diff --git a/README.zh-CN.md b/README.zh-CN.md index e46cfd7..e20f07e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -156,3 +156,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 处理密码和浏览器会话,运营者需配置独立个人项目。网站注册先由服务端检查人数上限,Supabase 公开注册保持关闭;迁移、上线状态与故障处理见部署文档。 diff --git a/docs/hosted.md b/docs/hosted.md index 69808b3..717008b 100644 --- a/docs/hosted.md +++ b/docs/hosted.md @@ -2,15 +2,17 @@ > This guide is for operators running a multi-user service. To deploy a personal pool without accounts, use the [default README instructions](../README.md#deploy-your-own-5-min): Worker + R2 + `AUTH_TOKEN`, with no D1 or Supabase. See [mode selection](deployment-modes.md). -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 owns passwords and browser sessions; there is no local PBKDF2/scrypt fallback. Login returns a short-lived Supabase access JWT held only in browser memory. The refresh token lives in a Secure/HttpOnly/SameSite cookie; `/api/account/refresh` rotates it and restores the session after reload. Protected requests send the access JWT as a bearer credential. The Worker verifies its signature using cached Supabase JWKS and checks the account state, authentication version and session revocation in D1. D1 retains application users, device tokens, recovery-code hashes, revocations and quotas; R2 retains files. Ordinary file requests need no provider call once signing keys are cached. -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. +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 stays disabled, including after launch; website registration remains available because 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 refresh cookies are scoped to the hosted origin. Logout revokes the Supabase session and records its session ID in D1 so an already issued access JWT cannot continue accessing ShotSync. Accepted access JWT lifetimes are at most 24 hours; successful remote logout retains the revocation for two days, and uncertain remote logout retains it indefinitely. An expired, correctly signed JWT can only revoke its session, never authorize a file request. Recovery increments the D1 authentication version before changing the provider password; signed application metadata must match that version. 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. File access, deletion and sharing resolve ownership from authenticated account IDs, never from caller-supplied user IDs. Files are not end-to-end encrypted: the service operator has access to storage. The UI doesn't persist credentials/files in localStorage or a service worker. Shared links are bearer credentials: anyone holding one can access its file until expiry or revocation. @@ -27,7 +29,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 login/refresh 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 | @@ -46,11 +48,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`, `SUPABASE_SECRET_KEY` and `SUPABASE_PUBLISHABLE_KEY` as Worker secrets. Password grants, refresh and logout use the Publishable Key; only server-side identity administration uses the Secret Key. `SUPABASE_JWKS_URL` is not needed: the verifier derives `/auth/v1/.well-known/jwks.json` from `SUPABASE_URL`. Use an asymmetric Supabase JWT signing key (ES256 or RS256). 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. @@ -62,12 +64,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 and device tokens remain hashed. Browser refresh tokens are not persisted in D1, and access JWTs are never stored in localStorage or sessionStorage. 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 access JWTs/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`. The provider `shotsync_auth_version` must also match D1 `auth_version` before the account is reactivated. 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 are now supplied and encrypted in the private secrets vault; the server secret is installed in Cloudflare. Real provider create/login/password-update/old-password-rejection tests passed and the disposable fixture was removed. Public signup and email confirmation are now disabled, with email/password login enabled. Real native ES256 login, refresh, logout, and same-password recovery invalidation of old refresh tokens have passed through an isolated Worker connected to the personal provider. Temporary provider fixtures were removed. The native-session migration is undergoing final review before deployment. 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..ba5f0b0 --- /dev/null +++ b/migrations/0004_managed_auth.sql @@ -0,0 +1,12 @@ +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')) +); + +CREATE TABLE revoked_auth_sessions (session_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL); diff --git a/package-lock.json b/package-lock.json index cf9cbd2..b495fd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,9 @@ "packages": { "": { "name": "shotsync-worker", + "dependencies": { + "jose": "^6.2.9" + }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.71", "@cloudflare/workers-types": "^5.20260820.1", @@ -2685,6 +2688,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", diff --git a/package.json b/package.json index ce7a2c1..1a781b9 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,8 @@ }, "engines": { "node": ">=22" + }, + "dependencies": { + "jose": "^6.2.9" } } diff --git a/scripts/check-hosted-config.mjs b/scripts/check-hosted-config.mjs index 8fd7b9d..6e20989 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.SUPABASE_PUBLISHABLE_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, SUPABASE_PUBLISHABLE_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..6757758 100644 --- a/scripts/test-hosted-browser.mjs +++ b/scripts/test-hosted-browser.mjs @@ -1,33 +1,116 @@ +import { webcrypto, randomUUID } from 'node:crypto'; 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 publishable = 'sb_publishable_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, shotsync_auth_version: 0 } }]]); +const providerCalls = []; +let refreshFailure=0; +const keyPair = await webcrypto.subtle.generateKey({name:'RSASSA-PKCS1-v1_5',modulusLength:2048,publicExponent:new Uint8Array([1,0,1]),hash:'SHA-256'},true,['sign','verify']); +const jwk = {...await webcrypto.subtle.exportKey('jwk',keyPair.publicKey),kid:'browser-fixture',alg:'RS256',use:'sig'}; +const refreshTokens = new Map(); +const providerSessions = new Map(); +const publicUser = user => ({ id: user.id, email: user.email, app_metadata: user.app_metadata }); +async function session(user, sessionId=randomUUID()) { + const now=Math.floor(Date.now()/1000); + const encode=value=>Buffer.from(JSON.stringify(value)).toString('base64url'); + const unsigned=encode({alg:'RS256',kid:jwk.kid,typ:'JWT'})+'.'+encode({iss:providerOrigin+'/auth/v1',sub:user.id,aud:'authenticated',role:'authenticated',email:user.email,app_metadata:user.app_metadata,iat:now,exp:now+3600,session_id:sessionId}); + const signature=await webcrypto.subtle.sign('RSASSA-PKCS1-v1_5',keyPair.privateKey,Buffer.from(unsigned)); + const refresh=randomUUID();refreshTokens.set(refresh,{user,sessionId});providerSessions.set(sessionId,user); + return {user:publicUser(user),access_token:unsigned+'.'+Buffer.from(signature).toString('base64url'),refresh_token:refresh,expires_at:now+3600,expires_in:3600,token_type:'bearer'}; +} +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'); + if(url.pathname==='/auth/v1/.well-known/jwks.json')return json({keys:[jwk]}); + expect(request.headers.get('apikey')).toBe(url.pathname.startsWith('/auth/v1/admin/')?secret:publishable); + if(url.pathname==='/auth/v1/logout') { + const jwt=request.headers.get('Authorization')?.replace('Bearer ',''); + const claims=JSON.parse(Buffer.from(jwt.split('.')[1],'base64url').toString()); + providerSessions.delete(claims.session_id); + for(const [token,value] of refreshTokens)if(value.sessionId===claims.session_id)refreshTokens.delete(token); + return new MiniflareResponse(null,{status:204}); + } + expect(request.headers.has('Authorization')).toBe(false); + providerCalls.push(`${request.method} ${url.pathname}`); + const body = request.method === 'GET' ? null : await request.json(); + if (url.pathname === '/auth/v1/token' && request.method === 'POST') { + if(url.searchParams.get('grant_type')==='refresh_token') { + if(refreshFailure)return json({error:'fixture temporary provider failure'},refreshFailure); + const current=refreshTokens.get(body.refresh_token);refreshTokens.delete(body.refresh_token); + return current&&providerSessions.has(current.sessionId)?json(await session(current.user,current.sessionId)):json({error_code:'refresh_token_not_found'},400); + } + const user = [...providerUsers.values()].find(user => user.email === body.email && user.password === body.password); + return user ? json(await session(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, SUPABASE_PUBLISHABLE_KEY: publishable, + 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(); + let lastAccessToken=''; + page.on('response',async response=>{if(['/api/account/login','/api/account/refresh'].includes(new URL(response.url()).pathname)&&response.ok()){const data=await response.json();lastAccessToken=data.accessToken||lastAccessToken;}}); + // 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(); + const firstAccessToken=lastAccessToken;expect(firstAccessToken.split('.')).toHaveLength(3); + await page.reload();await expect(page.locator('#app')).toBeVisible(); + expect(providerCalls.filter(call=>call==='POST /auth/v1/token').length).toBeGreaterThanOrEqual(2); + const beforeConcurrent=providerCalls.filter(call=>call==='POST /auth/v1/token').length; + await page.evaluate(async()=>{accessToken='invalid-fixture-access-token';await Promise.all([api('/api/list'),api('/api/account/devices')]);}); + expect(providerCalls.filter(call=>call==='POST /auth/v1/token').length).toBe(beforeConcurrent+1); await page.locator('#text').fill('跨设备取回测试');await page.locator('#text-form button').click(); await expect(page.locator('.tile')).toHaveCount(1); await page.getByRole('button',{name:'预览',exact:true}).click();await expect(page.locator('.textpreview')).toHaveText('跨设备取回测试'); @@ -43,26 +126,60 @@ try { await page.locator('#devices button').click();await expect(page.locator('#devices .device')).toHaveCount(0);expect((await privateContext.request.get(origin+'/api/list',{headers:{Authorization:'Bearer '+token}})).status()).toBe(401); 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})}); - }); + refreshFailure=429; + await page.evaluate(()=>{expiresAt=Date.now()-1;}); + await page.locator('#refresh').click(); + await expect(page.locator('#refresh')).toBeEnabled(); + await expect(page.locator('#status')).toContainText('登录服务暂时不可用'); + await expect(page.locator('#app')).toBeVisible(); + expect(await page.evaluate(()=>accessToken.length>0)).toBe(true); + refreshFailure=503; + await page.locator('#refresh').click(); + await expect(page.locator('#refresh')).toBeEnabled(); + await expect(page.locator('#status')).toContainText('登录服务暂时不可用'); + await expect(page.locator('#app')).toBeVisible(); + expect(await page.evaluate(()=>accessToken.length>0)).toBe(true); + const beforeLogout=providerCalls.filter(call=>call==='POST /auth/v1/token').length; + await page.locator('#logout').click();await expect(page.locator('#auth')).toBeVisible(); + expect(providerCalls.filter(call=>call==='POST /auth/v1/token').length).toBe(beforeLogout); + expect((await context.cookies()).some(cookie=>cookie.name==='__Host-shotsync-refresh')).toBe(false); + refreshFailure=0;expect(await page.locator('#token-value').textContent()).toBe(''); + expect((await context.request.get(origin+'/api/list',{headers:{Authorization:'Bearer '+firstAccessToken}})).status()).toBe(401); + await page.reload();await expect(page.locator('#auth')).toBeVisible();await expect(page.locator('#app')).toBeHidden(); 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('#password').fill(password);await page.locator('#auth-submit').click();await expect(page.locator('#app')).toBeVisible(); + const preRecoveryJWT=lastAccessToken; + const recoveryDeviceResponse=await context.request.post(origin+'/api/account/devices',{headers:{Authorization:'Bearer '+preRecoveryJWT,Origin:origin},data:{name:'recovery fixture'}}); + expect(recoveryDeviceResponse.status()).toBe(201); + const recoveryDevice=(await recoveryDeviceResponse.json()).token; + // Reset in a separate unauthenticated browser page, leaving the old session active. + const resetPage=await context.newPage();await resetPage.goto(origin);await expect(resetPage.locator('#app')).toBeVisible(); + await context.clearCookies();await page.reload();await expect(page.locator('#auth')).toBeVisible(); + await page.locator('#email').fill('new@example.com'); 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(); + expect((await context.request.get(origin+'/api/list',{headers:{Authorization:'Bearer '+preRecoveryJWT}})).status()).toBe(401); + expect((await context.request.get(origin+'/api/list',{headers:{Authorization:'Bearer '+recoveryDevice}})).status()).toBe(401); + await resetPage.close(); + 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(''); - 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'); + 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+sessionStorage.length)).toBe(0);expect(errors).toEqual([]); + console.log('PASS: real browser signed-JWT login, reload/rotating refresh, concurrent 401 singleflight, upload/private preview, device isolation, share/revoke, delete, logout revocation; registration/recovery rotates codes and invalidates old JWT/device, 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..7774638 100644 --- a/src/hosted/accounts.ts +++ b/src/hosted/accounts.ts @@ -1,12 +1,13 @@ 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, verifyAccessToken, refreshProviderSession, revokeProviderSession, type ProviderSession } 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 } -export interface Account { id: string; email: string; verified: boolean; via: 'cookie' | 'token' } +const COOKIE = '__Host-shotsync-refresh'; +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'; authVersion?: number; sessionId?: string; expiresAt?: number } 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(); @@ -43,19 +44,32 @@ async function challenge(request: Request, env: HostedEnv, token: unknown): Prom return result.success === true && result.hostname === new URL(env.PUBLIC_ORIGIN).hostname; } export async function authenticate(request: Request, env: HostedEnv): Promise { - const authorization = request.headers.get('Authorization'); - const bearer = authorization?.match(/^Bearer ([a-f0-9]{64})$/)?.[1]; - const raw = authorization ? bearer : sessionToken(request); - if (!raw || !/^[a-f0-9]{64}$/.test(raw)) return null; - const table = authorization ? 'device_tokens' : 'sessions'; - 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`) - .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; + const raw = request.headers.get('Authorization')?.match(/^Bearer ([^ ]+)$/)?.[1]; + if (!raw) return null; + if (/^[a-f0-9]{64}$/.test(raw)) { + const row = await env.DB.prepare(`SELECT u.id,u.email,u.verified_at FROM device_tokens 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(); + return row ? { ...publicUser(row), via: 'token' } : null; + } + const claims = await verifyAccessToken(env, raw); + if (!claims) return null; + const row = await env.DB.prepare(`SELECT * FROM users WHERE id=? AND auth_provider_id=? AND auth_state='active' AND auth_version=? AND NOT EXISTS (SELECT 1 FROM revoked_auth_sessions WHERE session_id=?)`) + .bind(claims.id, claims.id, claims.authVersion, claims.sessionId).first(); + return row ? { ...publicUser(row), via: 'cookie', authVersion: claims.authVersion, sessionId: claims.sessionId, expiresAt: claims.expiresAt } : null; +} +async function sessionReply(env: HostedEnv, session: ProviderSession, expectedVersion?: number): Promise { + const row = await env.DB.prepare(`SELECT * FROM users WHERE id=? AND auth_provider_id=? AND auth_state='active' AND auth_version=? AND NOT EXISTS (SELECT 1 FROM revoked_auth_sessions WHERE session_id=?)`) + .bind(session.id, session.id, session.authVersion, session.sessionId).first(); + if (!row || (expectedVersion !== undefined && row.auth_version !== expectedVersion)) return fail('Account changed. Please sign in again.', 409); + return reply({ user: publicUser(row), accessToken: session.accessToken, expiresAt: session.expiresAt }, 200, cookie(session.refreshToken)); } -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,20 +102,40 @@ 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`) - .bind(id, await tokenHash(token), body.name.trim(), now, now + 90 * DAY, user.id, await tokenHash(sessionToken(request) || ''), now, user.id, now).run(); + SELECT ?,?,id,?,?,?,auth_version FROM users WHERE id=? AND auth_state='active' AND auth_version=? AND NOT EXISTS (SELECT 1 FROM revoked_auth_sessions WHERE session_id=?) 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, user.authVersion, user.sessionId, user.id, now).run(); return inserted.meta.changes ? reply({ id, token }, 201) : fail('Maximum 10 active devices', 409); } return fail('Not found', 404); } if (request.method !== 'POST') return fail('Not found', 404); if (route === 'logout') { - const raw = sessionToken(request); - if (raw) await env.DB.prepare('DELETE FROM sessions WHERE hash=?').bind(await tokenHash(raw)).run(); + const bearer = request.headers.get('Authorization')?.match(/^Bearer ([^ ]+)$/)?.[1]; + const user = bearer ? await verifyAccessToken(env, bearer, true) : null; + if (user?.sessionId) { + // Keep the tombstone beyond any access JWT minted before remote revocation completes. + await env.DB.prepare('INSERT INTO revoked_auth_sessions(session_id,expires_at) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET expires_at=MAX(expires_at,excluded.expires_at)') + .bind(user.sessionId, Number.MAX_SAFE_INTEGER).run(); + try { + await revokeProviderSession(env, request.headers.get('Authorization')!.slice(7)); + await env.DB.prepare('UPDATE revoked_auth_sessions SET expires_at=? WHERE session_id=?').bind(Date.now() + 2 * DAY, user.sessionId).run(); + } catch { + // An ambiguous remote sign-out must never let the refresh token revive this session. + return reply({ ok: true, warning: 'Remote sign-out is pending; this session remains blocked.' }, 200, cookie('', 0)); + } + } return reply({ ok: true }, 200, cookie('', 0)); } + if (route === 'refresh') { + if (!configuredProvider(env)) return fail('Password authentication is temporarily unavailable', 503); + const raw = sessionToken(request); + if (!raw || !/^[a-zA-Z0-9_-]{10,2048}$/.test(raw)) return reply({ error: 'Sign in required' }, 401, cookie('', 0)); + if (await limited(env, `refresh:${await ipKey(request)}`, 60, 600)) return fail('Try again later', 429); + const session = await withAuthRequest(env.DB, () => refreshProviderSession(env, raw)); + return session ? sessionReply(env, session) : reply({ error: 'Sign in required' }, 401, 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 +147,69 @@ 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)); - 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), - 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), - ]); - return reply({ user: publicUser(user) }, 200, cookie(token)); + // 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 session = await withAuthRequest(env.DB, () => verifyProviderPassword(env, user?.auth_provider_id || '', email, body.password as string)); + if (!user || !session) return fail('Invalid email or password', 401); + return sessionReply(env, session, user.auth_version); } + 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, user.auth_version + 1); + else await createPasswordUser(env, providerId, email, body.password as string, operation, user.auth_version + 1); 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_PUBLISHABLE_KEY === 'string' && /^sb_publishable_[a-zA-Z0-9_-]{16,}$/.test(env.SUPABASE_PUBLISHABLE_KEY) && + 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, authVersion?: number): 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 || (authVersion !== undefined && value.app_metadata.shotsync_auth_version !== authVersion)) 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, accessToken?: string): 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 admin = path.startsWith('/admin/'); + const headers: Record = { apikey: admin ? env.SUPABASE_SECRET_KEY : env.SUPABASE_PUBLISHABLE_KEY, 'Content-Type': 'application/json' }; + // Modern secret keys are not JWTs; only legacy service_role keys use Bearer. + if (admin && legacyKey(env.SUPABASE_SECRET_KEY)) headers.Authorization = `Bearer ${env.SUPABASE_SECRET_KEY}`; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + 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 && ![400,401,403].includes(response.status)) { await response.body?.cancel(); throw new HttpError(503, UNAVAILABLE); } + return { status: response.status, data: response.status === 204 ? null : 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, authVersion = 0): 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, shotsync_auth_version: authVersion, ...(operation ? { shotsync_operation: operation } : {}) }, + }, true); + if (status < 200 || status >= 300) throw new HttpError(503, UNAVAILABLE); + identity(data, env, id, email, authVersion); + } catch (error) { + if (error instanceof ProviderMutationError) throw error; + throw new ProviderMutationError(error instanceof HttpError ? error.status : 503, false); + } +} +export interface ProviderSession { accessToken: string; refreshToken: string; expiresAt: number; id: string; email: string; authVersion: number; sessionId: string } +export interface ProviderClaims { id: string; authVersion: number; sessionId: string; expiresAt: number } +const jwks = new Map>(); +export async function verifyAccessToken(env: ProviderEnv, token: string, allowExpiredForLogout = false): Promise { + if (!configuredProvider(env) || token.length > 16384) return null; + let keys = jwks.get(env.SUPABASE_URL); + if (!keys) { keys = createRemoteJWKSet(new URL(`${env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`), { timeoutDuration: 5000 }); jwks.set(env.SUPABASE_URL, keys); } + try { + let payload: JWTPayload; + try { ({ payload } = await jwtVerify(token, keys, { issuer: `${env.SUPABASE_URL}/auth/v1`, audience: 'authenticated', algorithms: ['ES256', 'RS256'], requiredClaims: ['sub', 'exp', 'iat', 'session_id'] })); } + catch (error) { + // jose verifies signature, issuer, audience and not-before before reporting expiration. + // Only sign-out may use this payload; it can revoke a session but cannot authorize access. + if (!allowExpiredForLogout || !(error instanceof errors.JWTExpired) || error.claim !== 'exp') return null; + payload = error.payload; + } + const metadata = payload.app_metadata; + if (typeof payload.sub !== 'string' || !UUID.test(payload.sub) || typeof payload.session_id !== 'string' || !UUID.test(payload.session_id) || !object(metadata) || + metadata.shotsync_origin !== env.PUBLIC_ORIGIN || metadata.shotsync_user_id !== payload.sub || !Number.isSafeInteger(metadata.shotsync_auth_version) || (metadata.shotsync_auth_version as number) < 0 || + typeof payload.exp !== 'number' || typeof payload.iat !== 'number' || payload.iat > Math.floor(Date.now() / 1000) + 60 || payload.exp - payload.iat > 86400 || payload.exp > Math.floor(Date.now() / 1000) + 86400) return null; + return { id: payload.sub, authVersion: metadata.shotsync_auth_version as number, sessionId: payload.session_id, expiresAt: payload.exp * 1000 }; + } catch { return null; } +} +async function session(env: ProviderEnv, data: unknown): Promise { + if (!object(data) || typeof data.access_token !== 'string' || typeof data.refresh_token !== 'string' || !/^[a-zA-Z0-9_-]{10,2048}$/.test(data.refresh_token) || !object(data.user) || typeof data.user.id !== 'string' || typeof data.user.email !== 'string') throw new HttpError(503, UNAVAILABLE); + identity(data.user, env, data.user.id, data.user.email); + const claims = await verifyAccessToken(env, data.access_token); + if (!claims || claims.id !== data.user.id) throw new HttpError(503, UNAVAILABLE); + return { ...claims, accessToken: data.access_token, refreshToken: data.refresh_token, email: data.user.email }; +} +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 null; + if (status !== 200 || !object(data) || !object(data.user)) throw new HttpError(503, UNAVAILABLE); + if (id === '') return null; + identity(data.user, env, id, email); + return session(env, data); +} +export async function refreshProviderSession(env: ProviderEnv, refreshToken: string): Promise { + const { status, data } = await request(env, '/token?grant_type=refresh_token', 'POST', { refresh_token: refreshToken }); + if ([400, 401, 403].includes(status)) return null; + if (status !== 200) throw new HttpError(503, UNAVAILABLE); + return session(env, data); +} +export async function revokeProviderSession(env: ProviderEnv, accessToken: string): Promise { + const { status } = await request(env, '/logout?scope=local', 'POST', undefined, false, accessToken); + if (![200, 204].includes(status)) throw new HttpError(503, UNAVAILABLE); +} +export async function updateProviderPassword(env: ProviderEnv, id: string, email: string, password: string, operation?: string, authVersion = 0): 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, app_metadata: { shotsync_origin: env.PUBLIC_ORIGIN, shotsync_user_id: id, shotsync_auth_version: authVersion, ...(operation ? { shotsync_operation: operation } : {}) } }); + if (after.status !== 200) throw new HttpError(503, UNAVAILABLE); + identity(after.data, env, id, email, authVersion); +} diff --git a/src/hosted/types.ts b/src/hosted/types.ts index be77f31..3cb588f 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; SUPABASE_PUBLISHABLE_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 c22b7e7..da3bd2e 100644 --- a/src/hosted/ui.ts +++ b/src/hosted/ui.ts @@ -6,18 +6,20 @@ export function hostedHTML(env: HostedEnv): string { return `ShotSync · 随手传,随处取
ShotSync
-

随手传,随处取。

这里是公共账号服务。想把内容放在自己的 Cloudflare?自己部署,无需注册账号

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

欢迎回来

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

10–128 个字符
+

随手传,随处取。

这里是公共账号服务。想把内容放在自己的 Cloudflare?自己部署,无需注册账号

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

欢迎回来

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

至少 10 个字符;最多 72 个英文字符或 24 个汉字
`; } diff --git a/test/hosted-accounts.test.ts b/test/hosted-accounts.test.ts index 380c942..9fc2f9c 100644 --- a/test/hosted-accounts.test.ts +++ b/test/hosted-accounts.test.ts @@ -1,21 +1,29 @@ import { env } from 'cloudflare:test'; -import { createHmac, pbkdf2Sync } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HttpError } from '../src/hosted/http'; -import { authenticate, createUser, cleanupAccounts, handleAccounts } from '../src/hosted/accounts'; -import { hashPassword, randomToken, tokenHash, verifyPassword, withPasswordWork } from '../src/hosted/account-crypto'; +import { authenticate, reserveRegistration, cleanupAccounts, handleAccounts } from '../src/hosted/accounts'; +import { randomToken, tokenHash, withAuthRequest } from '../src/hosted/account-crypto'; +import * as provider from '../src/hosted/auth-provider'; import type { HostedEnv } from '../src/hosted/types'; // @ts-expect-error Vite imports migration text for the real local D1 database. import schema from '../migrations/0001_accounts.sql?raw'; // @ts-expect-error Vite imports migration text for the real local D1 database. import recoverySchema from '../migrations/0003_recovery.sql?raw'; +// @ts-expect-error Vite imports migration text. +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 pepper = 'ab'.repeat(32); +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; -let passwordHash: string; +const passwordHash = 'external:supabase'; function request(route: string, body?: unknown, headers: Record = {}, method?: string) { return new Request(`${origin}/api/account/${route}`, { method: method || (body === undefined ? 'GET' : 'POST'), headers: { Origin: origin, 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.1', ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); } @@ -25,75 +33,141 @@ async function call(route: string, body?: unknown, headers?: Record { - for (const statement of ((schema as string) + (recoverySchema as string)).split(';').filter(s => s.trim())) await db.prepare(statement).run(); + 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', PASSWORD_PEPPER: pepper, REGISTRATION_LIMIT: '100' } as unknown as HostedEnv; - passwordHash = await hashPassword(password, pepper); + 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, 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 ? issue(id,email,remote.version) : null; + }); + 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.version = authVersion; + }); }); afterEach(() => vi.restoreAllMocks()); describe('hosted accounts with real D1', () => { - it('uses a salted and peppered PBKDF2 verifier in the Worker runtime', async () => { - expect(passwordHash).toMatch(/^pbkdf2-sha256:v1:100000:[a-f0-9]{64}:[a-f0-9]{64}$/); - expect(await verifyPassword(password, passwordHash, pepper)).toBe(true); - expect(await verifyPassword('wrong-password', passwordHash, pepper)).toBe(false); - expect(await hashPassword(password, pepper)).not.toBe(passwordHash); - }); - it('matches an independent PBKDF2/HMAC calculation and never stores the bare verifier', async () => { - const salt = passwordHash.split(':')[3]; - const bare = pbkdf2Sync(password, Buffer.from(salt, 'hex'), 100_000, 32, 'sha256'); - const expected = createHmac('sha256', Buffer.from(pepper, 'hex')).update(bare).digest('hex'); - expect(passwordHash.split(':')[4]).toBe(expected); - expect(passwordHash).not.toContain(Buffer.from(bare).toString('hex')); - expect(await verifyPassword(password, passwordHash, 'cd'.repeat(32))).toBe(false); - }); - it('rejects missing or malformed pepper before creating or authenticating accounts', async () => { - await seed(); - for (const bad of ['', 'short', 'gg'.repeat(32), 'ab'.repeat(31)]) { - await expect(hashPassword(password, bad)).rejects.toMatchObject({ status: 503 }); - await expect(verifyPassword(password, passwordHash, bad)).rejects.toMatchObject({ status: 503 }); - bindings.PASSWORD_PEPPER = bad; - for (const route of ['login', 'register', 'reset-password']) { - expect((await call(route, { email: 'person@example.com', password, turnstileToken: 'captcha' })).status).toBe(503); - } - } - expect(await db.prepare('SELECT hash FROM sessions').first()).toBeNull(); - expect((await db.prepare('SELECT COUNT(*) n FROM users').first())?.n).toBe(1); - }); - it('rejects legacy hashes, unsupported versions, changed parameters and malformed values', async () => { - const salt = passwordHash.split(':')[3], digest = passwordHash.split(':')[4]; - for (const stored of [ - `scrypt:16384:8:5:${salt}:${digest}`, - passwordHash.replace(':v1:', ':v2:'), - passwordHash.replace(':100000:', ':1:'), - passwordHash.replace(salt, salt.slice(2)), - passwordHash.replace(digest, 'gg'.repeat(32)), - `${passwordHash}:extra`, '', - ]) expect(await verifyPassword(password, stored, pepper)).toBe(false); - }); - it('performs password work for unknown addresses and never issues a session', async () => { + it('fails closed without provider configuration and performs no local password derivation', async () => { const derive = vi.spyOn(crypto.subtle, 'deriveBits'); + bindings.SUPABASE_SECRET_KEY = ''; + for (const route of ['login', 'register', 'reset-password']) { + expect((await call(route, { email: 'person@example.com', password, turnstileToken: 'captcha' })).status).toBe(503); + } + expect(provider.verifyProviderPassword).not.toHaveBeenCalled(); + expect(derive).not.toHaveBeenCalled(); + }); + it('delegates unknown-account password checks without issuing a local session', async () => { expect((await call('login', { email: 'unknown@example.com', password })).status).toBe(401); - expect(derive).toHaveBeenCalledOnce(); - expect(derive.mock.calls[0][0]).toMatchObject({ name: 'PBKDF2', hash: 'SHA-256', iterations: 100_000 }); + expect(provider.verifyProviderPassword).toHaveBeenCalledWith(bindings, '', 'unknown@example.com', password); expect(await db.prepare('SELECT hash FROM sessions').first()).toBeNull(); }); - it('allows recovery from a legacy verifier without accepting the old hash', async () => { - const id = await seed(), recoveryCode = randomToken(); - await db.prepare('UPDATE users SET password_hash=?,recovery_hash=? WHERE id=?') - .bind(`scrypt:16384:8:5:${'ab'.repeat(32)}:${'cd'.repeat(32)}`, await tokenHash(recoveryCode), id).run(); + it('does not link an unrelated provider identity by matching email', async () => { + await seed(); + 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 () => { + await seed(); + vi.mocked(provider.verifyProviderPassword).mockRejectedValue(new HttpError(503, 'Provider unavailable')); + expect((await call('login', { email: 'person@example.com', password })).status).toBe(503); + expect(await db.prepare('SELECT hash FROM sessions').first()).toBeNull(); + }); + it('migrates legacy credentials only with the existing recovery code', async () => { + const id = await seed(), recoveryCode = randomToken(); + providerUsers.delete('person@example.com'); + await db.prepare("UPDATE users SET auth_state='legacy',auth_provider_id=NULL,password_hash='legacy-hash',recovery_hash=? WHERE id=?") + .bind(await tokenHash(recoveryCode), id).run(); + expect((await call('login', { email: 'person@example.com', password })).status).toBe(409); expect((await call('reset-password', { email: 'person@example.com', recoveryCode, password, turnstileToken: 'captcha' })).status).toBe(200); expect((await call('login', { email: 'person@example.com', password })).status).toBe(200); - expect((await db.prepare('SELECT password_hash FROM users WHERE id=?').bind(id).first())?.password_hash).toMatch(/^pbkdf2-sha256:v1:100000:/); + expect((await db.prepare('SELECT password_hash,auth_provider_id FROM users WHERE id=?').bind(id).first())).toMatchObject({ password_hash: 'external:supabase', auth_provider_id: id }); + }); + it('keeps uncertain registration reserved and never retries a possibly completed mutation', async () => { + bindings.REGISTRATION_LIMIT = '1'; + vi.mocked(provider.createPasswordUser).mockRejectedValue(new provider.ProviderMutationError(503, false)); + expect((await call('register', { email: 'pending@example.com', password, turnstileToken: 'captcha' })).status).toBe(503); + expect((await call('register', { email: 'pending@example.com', password, turnstileToken: 'captcha' })).status).toBe(409); + expect((await call('register', { email: 'other@example.com', password, turnstileToken: 'captcha' })).status).toBe(409); + expect(provider.createPasswordUser).toHaveBeenCalledOnce(); + expect(await db.prepare('SELECT state FROM auth_registrations').first()).toEqual({ state: 'pending' }); + await db.prepare('UPDATE auth_registrations SET created_at=0').run(); + await cleanupAccounts(db); + expect(await db.prepare('SELECT state FROM auth_registrations').first()).toEqual({ state: 'pending' }); + }); + it('releases only definitively rejected registrations and allows a new attempt', async () => { + bindings.REGISTRATION_LIMIT = '1'; + vi.mocked(provider.createPasswordUser).mockRejectedValueOnce(new provider.ProviderMutationError(503, true)); + expect((await call('register', { email: 'new@example.com', password, turnstileToken: 'captcha' })).status).toBe(503); + expect(await db.prepare('SELECT state FROM auth_registrations').first()).toEqual({ state: 'failed' }); + expect((await call('register', { email: 'new@example.com', password, turnstileToken: 'captcha' })).status).toBe(201); + expect(provider.createPasswordUser).toHaveBeenCalledTimes(2); + }); + it('uncertain recovery blocks all old credentials and later password mutations', async () => { + 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' }, { 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, { Authorization: cookie }), bindings)).toBeNull(); + expect(await authenticate(request('me', undefined, { Authorization: 'Bearer ' + device.token }), bindings)).toBeNull(); + 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(); + await cleanupAccounts(db); + expect((await db.prepare('SELECT auth_state,auth_version,recovery_hash FROM users WHERE id=?').bind(id).first())).toMatchObject({ auth_state: 'resetting', auth_version: 1, recovery_hash: await tokenHash(recoveryCode) }); + }); + it('cannot issue a session from a provider response that crosses a recovery boundary', async () => { + 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 issue(id); + }); + const response = await call('login', { email: 'person@example.com', password }); + expect(response.status).toBe(409); + expect(response.headers.has('Set-Cookie')).toBe(false); + expect(await db.prepare('SELECT hash FROM sessions').first()).toBeNull(); + }); + it('a stale login cannot delete a newer post-recovery session', async () => { + const id = await seed(), newToken = randomToken(); + vi.mocked(provider.verifyProviderPassword).mockImplementationOnce(async () => { + await db.batch([ + 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 issue(id); + }); + expect((await call('login', { email: 'person@example.com', password })).status).toBe(409); + 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); + expect((await call('register', { email: 'too-long@example.com', password: over, turnstileToken: 'captcha' })).status).toBe(400); + expect(provider.createPasswordUser).not.toHaveBeenCalled(); + expect((await call('register', { email: 'valid@example.com', password: valid, turnstileToken: 'captcha' })).status).toBe(201); + expect(provider.createPasswordUser).toHaveBeenCalledWith(bindings, expect.any(String), 'valid@example.com', valid); + expect((await call('login', { email: 'valid@example.com', password: over })).status).toBe(401); }); it('rejects cross-origin and form mutations', async () => { expect((await call('login', {}, { Origin: 'https://evil.test' })).status).toBe(403); @@ -102,50 +176,50 @@ 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 password work atomically and releases every lease', async () => { + it('caps concurrent provider sign-ins atomically and releases every lease', async () => { let release!: () => void; const held = new Promise(resolve => { release = resolve; }); let started!: () => void; const ready = new Promise(resolve => { started = resolve; }); const work = async () => { started(); await held; return 'ok'; }; - const a = withPasswordWork(db, work); + const a = withAuthRequest(db, work); await ready; - await expect(withPasswordWork(db, async () => 'blocked')).rejects.toMatchObject({ status: 429 }); + await expect(withAuthRequest(db, async () => 'blocked')).rejects.toMatchObject({ status: 429 }); release(); expect(await a).toBe('ok'); expect((await db.prepare('SELECT COUNT(*) n FROM password_leases').first<{ n: number }>())!.n).toBe(0); - await expect(withPasswordWork(db, async () => { throw new Error('failed'); })).rejects.toThrow('failed'); + await expect(withAuthRequest(db, async () => { throw new Error('failed'); })).rejects.toThrow('failed'); expect((await db.prepare('SELECT COUNT(*) n FROM password_leases').first<{ n: number }>())!.n).toBe(0); }); - it('reclaims abandoned expired password leases', async () => { + it('reclaims abandoned expired provider request leases', async () => { await db.prepare('INSERT INTO password_leases(id,expires_at) VALUES(?,?)').bind('expired', Date.now() - 1).run(); - expect(await withPasswordWork(db, async () => 'recovered')).toBe('recovered'); + expect(await withAuthRequest(db, async () => 'recovered')).toBe('recovered'); expect((await db.prepare('SELECT COUNT(*) n FROM password_leases').first<{ n: number }>())!.n).toBe(0); }); @@ -159,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 () => { @@ -172,11 +246,11 @@ describe('hosted accounts with real D1', () => { it('atomically admits only one account into the final trial slot including unverified accounts', async () => { await seed('existing@example.com', false); const results = await Promise.all([ - createUser(db, 'a', 'a@example.com', passwordHash, await tokenHash(randomToken()), 2), - createUser(db, 'b', 'b@example.com', passwordHash, await tokenHash(randomToken()), 2), + reserveRegistration(db, 'a', 'a@example.com', 2), + reserveRegistration(db, 'b', 'b@example.com', 2), ]); expect(results.sort()).toEqual([false, true]); - expect((await db.prepare('SELECT COUNT(*) n FROM users').first<{ n: number }>())!.n).toBe(2); + expect((await db.prepare("SELECT (SELECT COUNT(*) FROM users)+(SELECT COUNT(*) FROM auth_registrations WHERE state='pending') n").first<{ n: number }>())!.n).toBe(2); bindings.REGISTRATION_LIMIT = '2'; expect((await call('register', { email: 'c@example.com', password, turnstileToken: 'captcha' })).status).toBe(409); }); @@ -191,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)]); @@ -200,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); @@ -211,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); @@ -232,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(); @@ -244,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 new file mode 100644 index 0000000..198cfa1 --- /dev/null +++ b/test/hosted-auth-provider.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +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', 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, 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(); }); + +describe('hosted password provider', () => { + it('accepts canonical Supabase origins and privileged keys only', () => { + expect(configuredProvider(env)).toBe(true); + for (const SUPABASE_URL of ['', 'http://abcdefghijklmnopqrst.supabase.co', `${env.SUPABASE_URL}/`, `${env.SUPABASE_URL}/foo`, `${env.SUPABASE_URL}?x=1`, 'https://localhost', 'https://abcdefghijklmnopqrst.supabase.co.evil.test', 'https://key@abcdefghijklmnopqrst.supabase.co', 'https://abcdefghijklmnopqrst.supabase.co:443']) { + expect(configuredProvider({ ...env, SUPABASE_URL })).toBe(false); + } + for (const SUPABASE_SECRET_KEY of ['', 'sb_publishable_fixture', 'secret', 'eyJ.invalid.key']) expect(configuredProvider({ ...env, SUPABASE_SECRET_KEY })).toBe(false); + }); + it('creates the exact identity without email delivery and never uses modern keys as Bearer', async () => { + await createPasswordUser(env, id, email, password); + expect(fetch).toHaveBeenCalledOnce(); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + expect(url).toBe(`${env.SUPABASE_URL}/auth/v1/admin/users`); + expect(init).toMatchObject({ method: 'POST', redirect: 'manual', headers: { apikey: env.SUPABASE_SECRET_KEY } }); + expect(new Headers(init?.headers).has('Authorization')).toBe(false); + expect(JSON.parse(init!.body as string)).toEqual({ id, email, password, email_confirm: true, app_metadata: user().app_metadata }); + }); + it('sends legacy service_role JWT as both apikey and Bearer', async () => { + const key = `${btoa('{}').replace(/=/g, '')}.${btoa(JSON.stringify({ role: 'service_role' })).replace(/=/g, '')}.signature`; + await createPasswordUser({ ...env, SUPABASE_SECRET_KEY: key }, id, email, password); + expect(new Headers(vi.mocked(fetch).mock.calls[0][1]?.headers).get('Authorization')).toBe(`Bearer ${key}`); + 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 () => { + 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 })); + await expect(verifyProviderPassword(env, id, email, password)).rejects.toMatchObject({ status: 503 }); + } + }); + 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)).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)).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' }); + } + vi.mocked(fetch).mockResolvedValue(Response.json({}, { status: 429 })); + await expect(verifyProviderPassword(env, id, email, password)).rejects.toMatchObject({ status: 429 }); + }); + it('distinguishes definitive create rejection from ambiguous mutation results', async () => { + for (const status of [400, 401, 403, 422, 429]) { + vi.mocked(fetch).mockResolvedValue(Response.json({}, { status })); + await expect(createPasswordUser(env, id, email, password)).rejects.toMatchObject({ definitive: true, status: status === 429 ? 429 : 503 }); + } + for (const response of [Response.json({}, { status: 500 }), Response.json({ ...user(), id: crypto.randomUUID() }), new Response('not-json'), Response.json({})]) { + vi.mocked(fetch).mockResolvedValue(response); + await expect(createPasswordUser(env, id, email, password)).rejects.toMatchObject({ definitive: false, status: 503 }); + } + 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 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); + expect(vi.mocked(fetch).mock.calls[0][1]?.method).toBe('GET'); + 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,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(); + }); + it('rejects mutation response identity mismatch and oversized responses', async () => { + vi.mocked(fetch).mockResolvedValueOnce(Response.json(user())).mockResolvedValueOnce(Response.json({ ...user(), app_metadata: {} })); + await expect(updateProviderPassword(env, id, email, password)).rejects.toMatchObject({ status: 503 }); + vi.mocked(fetch).mockResolvedValue(new Response('x'.repeat(65537))); + await expect(verifyProviderPassword(env, id, email, password)).rejects.toMatchObject({ status: 503 }); + }); + it('times out after ten seconds without retries and classifies creation as ambiguous', async () => { + vi.useFakeTimers(); + vi.mocked(fetch).mockImplementation(() => new Promise(() => {})); + const result = expect(createPasswordUser(env, id, email, password)).rejects.toMatchObject({ definitive: false, status: 503 }); + await vi.advanceTimersByTimeAsync(10_000); + await result; + expect(fetch).toHaveBeenCalledOnce(); + expect(vi.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true); + }); + it('fails invalid configuration and IDs before sending credentials', async () => { + await expect(createPasswordUser({ ...env, SUPABASE_URL: 'http://evil.test' }, id, email, password)).rejects.toMatchObject({ definitive: false, status: 503 }); + 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 6d2d2ec..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, PASSWORD_PEPPER: 'ab'.repeat(32), 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'); }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 991681a..db07740 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=wrangler.hosted.jsonc --include-runtime=false --strict-vars=false --env-interface=HostedBindings` (hash: 67d6cfae54f0a62dcc67e974e54aeba4) +// Generated by Wrangler by running `wrangler types --config=wrangler.hosted.jsonc --include-runtime=false --strict-vars=false --env-interface=HostedBindings` (hash: 9056e8fff016890a119ba4cda5cf4826) interface __BaseEnv_HostedBindings { BUCKET: R2Bucket; DB: D1Database; @@ -7,6 +7,7 @@ interface __BaseEnv_HostedBindings { TURNSTILE_SITE_KEY: string; REGISTRATION_LIMIT: string; UPLOADS_ENABLED: string; + SUPABASE_URL: string; } declare namespace Cloudflare { interface GlobalProps { @@ -19,5 +20,5 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } diff --git a/wrangler.hosted.jsonc b/wrangler.hosted.jsonc index 9b3b2c3..fce9ca3 100644 --- a/wrangler.hosted.jsonc +++ b/wrangler.hosted.jsonc @@ -13,7 +13,8 @@ "PUBLIC_ORIGIN": "https://shotsync-hosted.defiabell.workers.dev", "TURNSTILE_SITE_KEY": "0x4AAAAAAE8wLBNWAJtUWmVb", "REGISTRATION_LIMIT": "100", - "UPLOADS_ENABLED": "1" + "UPLOADS_ENABLED": "1", + "SUPABASE_URL": "https://mjtagsjmnsgqirtvlpzf.supabase.co" }, "d1_databases": [ {