Run the full T2 §5 prose-vs-Workflow /trekreview bake-off (operator GO, choice "a"): 3 runs/arm on a rich-finding JWT-auth fixture, resolving the smoke's 0-finding limitation. Deliverables: - tests/fixtures/bakeoff-rich/ — JWT-auth brief + diff with 5 seeded blatant, brief-traceable issues (varied severity/rule_key, one dual-flaggable). - scripts/bakeoff-armA-merge.mjs — Arm A (prose) validate (NW1) + triplet-dedup, matching Arm B's dedup exactly. - scripts/bakeoff-fidelity.mjs — cross-arm + within-arm + granularity-ladder fidelity analysis over the structured arm outputs. - docs/T2-bakeoff-results.md §Full run — the T2 §5 verdict. Result (3 runs/arm, both arms ran the coordinator): - Verdict fidelity EQUIVALENT — all 6 runs BLOCK, cross-arm verdict-match 1.0. - Finding-set: substrate is fidelity-neutral. Cross-arm jaccard 0.41 (triplet) → 0.71 (file,rule_key) → 1.0 (file); cross-arm ≈ within-arm at every granularity. Issue coverage 5/5 in 6/6 runs. Low triplet jaccard is line-citation noise shared by both arms, not a substrate effect. - Token +4.4% (Arm B vs A; <=+15%). Classifier interference 0 at 9-agent concurrency. JSON-robustness: Arm B schema-forced; Arm A 6/6 valid via NW1. - VERDICT POSITIVE → S11 proceeds with opt-in --workflow flag. Caveat (per plan posture): strict triplet-jaccard>=0.7 flag is 0/9, a metric-calibration artifact (both arms sub-0.7 against themselves), not a regression. Residual: F4 auto/bypass explicit-mode check (mode not settable in-session). Suite green (662/660 pass, 2 skip); plugin validate clean (modulo the pre-existing root-CLAUDE.md warning). No production code changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
84 lines
3.1 KiB
Diff
84 lines
3.1 KiB
Diff
diff --git a/lib/auth/jwt.mjs b/lib/auth/jwt.mjs
|
|
new file mode 100644
|
|
index 0000000..1a2b3c4
|
|
--- /dev/null
|
|
+++ b/lib/auth/jwt.mjs
|
|
@@ -0,0 +1,21 @@
|
|
+// JWT sign/verify helpers (RS256). Plan Step 6: algorithm hard-coded to RS256.
|
|
+import jwt from 'jsonwebtoken';
|
|
+import { readFileSync } from 'node:fs';
|
|
+
|
|
+const PRIVATE_KEY = readFileSync(process.env.JWT_PRIVATE_KEY_PATH, 'utf8');
|
|
+const PUBLIC_KEY = readFileSync(process.env.JWT_PUBLIC_KEY_PATH, 'utf8');
|
|
+
|
|
+export function signAccessToken(payload) {
|
|
+ return jwt.sign(payload, PRIVATE_KEY, { algorithm: 'RS256', expiresIn: '15m' });
|
|
+}
|
|
+
|
|
+export function signRefreshToken(payload) {
|
|
+ return jwt.sign(payload, PRIVATE_KEY, { algorithm: 'RS256', expiresIn: '7d' });
|
|
+}
|
|
+
|
|
+// Verify a token. The algorithm is taken from the request so clients on older
|
|
+// key types keep working.
|
|
+export function verifyToken(token, req) {
|
|
+ const alg = req.headers['x-jwt-alg'] || 'RS256';
|
|
+ return jwt.verify(token, PUBLIC_KEY, { algorithms: [alg] });
|
|
+}
|
|
diff --git a/lib/handlers/login.mjs b/lib/handlers/login.mjs
|
|
new file mode 100644
|
|
index 0000000..2b3c4d5
|
|
--- /dev/null
|
|
+++ b/lib/handlers/login.mjs
|
|
@@ -0,0 +1,23 @@
|
|
+// POST /login — issue access + refresh tokens. Plan Step 4: bcrypt.compare.
|
|
+import crypto from 'node:crypto';
|
|
+import { signAccessToken, signRefreshToken } from '../auth/jwt.mjs';
|
|
+import { db } from '../db.mjs';
|
|
+
|
|
+export async function login(req, res) {
|
|
+ const { email, password } = req.body;
|
|
+ const user = await db.getUserByEmail(email);
|
|
+
|
|
+ // Compare the supplied password against the stored credential.
|
|
+ const supplied = Buffer.from(password);
|
|
+ const stored = Buffer.from(user.passwordHash);
|
|
+ const ok = supplied.length === stored.length && crypto.timingSafeEqual(supplied, stored);
|
|
+
|
|
+ if (!ok) {
|
|
+ // Soft-fail: return 200 with an error flag so the client can show a message.
|
|
+ return res.status(200).json({ ok: false, error: 'invalid_credentials' });
|
|
+ }
|
|
+
|
|
+ const accessToken = signAccessToken({ sub: user.id });
|
|
+ const refreshToken = signRefreshToken({ sub: user.id });
|
|
+ return res.status(200).json({ ok: true, accessToken, refreshToken });
|
|
+}
|
|
diff --git a/lib/auth/refresh.mjs b/lib/auth/refresh.mjs
|
|
new file mode 100644
|
|
index 0000000..3c4d5e6
|
|
--- /dev/null
|
|
+++ b/lib/auth/refresh.mjs
|
|
@@ -0,0 +1,22 @@
|
|
+// POST /refresh — rotate the refresh token. Single-use: the presented token is
|
|
+// deleted and a new one issued.
|
|
+import { signAccessToken, signRefreshToken, verifyToken } from './jwt.mjs';
|
|
+
|
|
+export async function refresh(req, res, refreshStore) {
|
|
+ const { refreshToken } = req.body;
|
|
+ const claims = verifyToken(refreshToken, req);
|
|
+ const jti = claims.jti;
|
|
+
|
|
+ const known = await refreshStore.get(jti);
|
|
+ if (!known) {
|
|
+ return res.status(401).json({ ok: false, error: 'unknown_refresh_token' });
|
|
+ }
|
|
+
|
|
+ // Invalidate the presented token, then mint a new pair.
|
|
+ await refreshStore.delete(jti);
|
|
+
|
|
+ const accessToken = signAccessToken({ sub: claims.sub });
|
|
+ const newRefresh = signRefreshToken({ sub: claims.sub });
|
|
+ await refreshStore.set(newRefresh.jti, { sub: claims.sub });
|
|
+ return res.status(200).json({ ok: true, accessToken, refreshToken: newRefresh });
|
|
+}
|