import { mkdirSync, writeFileSync, existsSync, readFileSync, renameSync, chmodSync, chownSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import assert from 'node:assert/strict'; import QRCode from 'qrcode'; import { authConfig, newAuthConfig, signSession, totp, isLoginRedirect } from '../src/lib/auth'; import { db } from '../src/lib/db'; async function main() { const command = process.argv[2]; const file = process.env.AUTH_CONFIG_FILE; if (!file) throw Error('Set AUTH_CONFIG_FILE before running the server authentication tool'); if (command === 'init' || command === 'rotate') { if (command === 'rotate' && !process.argv.includes('--confirm')) throw Error('Rotation invalidates all authenticators and sessions; pass --confirm to proceed'); if (command === 'init' && existsSync(file)) { authConfig(); console.log('Authentication configuration already exists; preserved'); return; } mkdirSync(dirname(file),{recursive:true,mode:0o700}); const temporary = file + '.' + process.pid + '.tmp'; writeFileSync(temporary,JSON.stringify(newAuthConfig())+'\n',{mode:0o600,flag:'wx'}); if (process.getuid?.() === 0) { chownSync(dirname(file),1000,1000); chownSync(temporary,1000,1000); } chmodSync(dirname(file),0o700); renameSync(temporary,file); console.log('Authentication configuration saved privately. Use the qr command in an SSH terminal to bind.'); return; } const config = authConfig(); if (!config) throw Error('Authentication is not configured'); if (command === 'qr') { if (!process.stdout.isTTY) throw Error('QR display requires an interactive SSH terminal (ssh -t, docker compose exec without -T). Never send QR output to CI logs.'); const uri = 'otpauth://totp/PROMPT:shared?'+new URLSearchParams({secret:config.secret,issuer:'PROMPT',algorithm:'SHA1',digits:'6',period:'30'}); console.log('Private binding QR — everyone scanning this uses the same authenticator key.'); console.log(await QRCode.toString(uri,{type:'terminal',small:true,errorCorrectionLevel:'M'})); return; } if (command === 'token') { if (!process.stdout.isTTY) throw Error('Tokens may only be displayed in an interactive SSH terminal'); console.log(signSession(config,undefined,3600)); return; } const base = process.env.AUTH_CHECK_BASE_URL || 'http://127.0.0.1:3000'; if (command === 'run') { const script = process.argv[3]; if (!script) throw Error('Provide a server-side script path'); const original = globalThis.fetch; const token = signSession(config,undefined,600); // Only attach the credential to the local application, never to remote fetches. globalThis.fetch = (input,init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.origin === new URL(base).origin) { const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); headers.set('Authorization','Bearer '+token); return original(input,{...init,headers}); } return original(input,init); }; await import(pathToFileURL(resolve(script)).href); return; } if (command === 'verify' || command === 'exercise') { const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'}); const buildId = readFileSync('.next/BUILD_ID','utf8').trim(); for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path); for(const path of ['/favicon.ico','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/apple-touch-icon.png']) assert.equal((await request(path)).status,200,'Public brand asset '+path); const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace'); for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)} assert.equal((await request('/account')).status,200,'Public account page'); const homeData = await request(`/_next/data/${buildId}/index.json`); assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages'); assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base)); assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights'); for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) { const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination); } for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path); assert.equal((await request('/api/posts',{method:'POST',body:'{}'})).status,401); assert.equal((await request('/api/feed',{headers:{'x-middleware-subrequest':'proxy:proxy:proxy:proxy:proxy'}})).status,401); const page = await request('/login'); assert.equal(page.status,200); const html = await page.text(); assert.ok(html.includes('输入动态码')); assert.ok(!html.includes(config.secret)); assert.equal((await request('/api/health')).status,200); const headers = {Authorization:'Bearer '+signSession(config,undefined,600)}; assert.equal((await request('/api/feed',{headers})).status,200); assert.equal((await request('/api/feed',{headers:{Authorization:headers.Authorization+'x'}})).status,401); if (command === 'exercise') { const origin = new URL(base).origin; const login = (code:string,customOrigin=origin) => request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Origin:customOrigin},body:JSON.stringify({code})}); assert.equal((await login('000000','https://untrusted.example')).status,403); const code = totp(config.secret,Math.floor(Date.now()/30000)); const results = await Promise.all([login(code),login(code)]); assert.deepEqual(results.map(r=>r.status).sort(),[200,401],'exactly one concurrent use succeeds'); const cookie = results.find(r=>r.status===200)!.headers.get('set-cookie')!; assert.match(cookie,/HttpOnly/); assert.match(cookie,/SameSite=Lax/); assert.match(cookie,/Max-Age=604800/); const session = cookie.split(';')[0]; assert.equal((await request('/api/feed',{headers:{Cookie:session}})).status,200); assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:session,Origin:'https://untrusted.example'}})).status,403); const logout = await request('/api/auth/logout',{method:'POST',headers:{Cookie:session,Origin:origin}}); assert.equal(logout.status,200); assert.match(logout.headers.get('set-cookie')!,/Max-Age=0/); let status = 0; for (let i=0;i<21;i++) status=(await login('invalid')).status; assert.equal(status,429); // Durable throttling remains even after this test process exits. assert.equal((await login(code)).status,429); } console.log('Dynamic-code authentication '+command+' passed (no secrets displayed)'); return; } throw Error('Usage: auth-cli.cjs init | qr | rotate --confirm | token | run