From c0e31116dd9406257a16267e08e5b8b55ea6f76f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:51:42 +0900 Subject: [PATCH 1/5] fix(security): enforce strict database-backed session revocation --- server/auth.mjs | 144 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 131 insertions(+), 13 deletions(-) diff --git a/server/auth.mjs b/server/auth.mjs index a16a7281..d8e147be 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -2,13 +2,30 @@ // Passwords: scrypt. Tokens: HS256 JWT with a PINNED algorithm (no header-alg // trust → immune to alg-confusion). This is a security boundary; do not simplify. import { scryptSync, randomBytes, timingSafeEqual, createHmac, createHash } from 'node:crypto'; +import { db } from './db.mjs'; -// Personal Access Tokens. Format: swk_. Only the SHA-256 hash is -// stored; the full secret is shown to the user exactly once at creation. +/** Maximum lifetime for a general ScopeWeave session token, in seconds. */ +const MAX_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + +/** + * Generate a one-time-visible ScopeWeave personal access token. + * + * Only the SHA-256 hash is suitable for persistence. The `full` value must be + * shown exactly once, while `prefix` is safe for later identification. + * + * @returns {{full:string,prefix:string,hash:string}} Token material and safe metadata. + */ export function generateApiToken() { const full = `swk_${randomBytes(24).toString('base64url')}`; return { full, prefix: full.slice(0, 12), hash: createHash('sha256').update(full).digest('hex') }; } + +/** + * Hash a personal access token for constant-shape database lookup. + * + * @param {unknown} full - Full token supplied by a client. + * @returns {string} Lowercase hexadecimal SHA-256 digest. + */ export function hashApiToken(full) { return createHash('sha256').update(String(full)).digest('hex'); } @@ -25,10 +42,15 @@ if ( throw new Error('SCOPEWEAVE_JWT_SECRET must be set to at least 32 non-whitespace characters'); } -// scryptSync requires string|ArrayBufferView — untyped JSON bodies must not -// throw TypeError (request-level DoS). hashPassword coerces non-strings to '' -// for a stable hash path; verifyPassword rejects non-strings with false so a -// malicious `{}` body never authenticates even if an empty-password hash exists. +/** + * Hash a password with a fresh random salt using Node's scrypt implementation. + * + * Non-string values are normalized to an empty string so an untyped request + * cannot crash the process. API boundaries must still reject non-string inputs. + * + * @param {unknown} pw - Password value to hash. + * @returns {string} Persistable `salt:hash` representation. + */ export function hashPassword(pw) { const password = typeof pw === 'string' ? pw : ''; const salt = randomBytes(16).toString('hex'); @@ -36,6 +58,16 @@ export function hashPassword(pw) { return `${salt}:${hash}`; } +/** + * Verify a candidate password against a stored scrypt representation. + * + * Non-string candidates and malformed stored values fail closed. Equal-length + * digests are compared with `timingSafeEqual` to avoid content-dependent timing. + * + * @param {unknown} pw - Candidate password. + * @param {unknown} stored - Persisted `salt:hash` representation. + * @returns {boolean} Whether the candidate matches the stored password hash. + */ export function verifyPassword(pw, stored) { if (typeof pw !== 'string') return false; const [salt, hash] = String(stored || '').split(':'); @@ -45,9 +77,54 @@ export function verifyPassword(pw, stored) { return test.length === known.length && timingSafeEqual(test, known); } -const b64urlJson = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); +/** + * Serialize a JSON value using the unpadded base64url form required by JWT. + * + * @param {unknown} value - JSON-serializable value. + * @returns {string} Base64url-encoded JSON. + */ +const b64urlJson = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * Determine whether a decoded JWT segment is a non-array JSON object. + * + * @param {unknown} value - Decoded JSON value. + * @returns {value is Record} Whether the value is a claims object. + */ +function isClaimsObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Sign a ScopeWeave session JWT with pinned HS256 semantics. + * + * Session tokens are minted only for a positive safe-integer user subject and a + * non-negative safe-integer token version. The lifetime must be a positive safe + * integer no greater than seven days, so an internal caller cannot create an + * immortal, already-expired, excessively long-lived, or numerically imprecise + * general session token. Narrower credentials use the separate access-grant + * design tracked in issue #413 rather than extending this lifetime. + * + * @param {Record} payload - Session claims to include. + * @param {number} [ttlSec=604800] - Token lifetime in seconds, at most seven days. + * @returns {string} Signed compact JWT. + * @throws {TypeError|RangeError} If the payload, subject, token version, or lifetime is invalid. + */ +export function signToken(payload, ttlSec = MAX_SESSION_TTL_SECONDS) { + if (!isClaimsObject(payload)) throw new TypeError('session claims must be an object'); + if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { + throw new TypeError('session subject must be a positive safe integer'); + } + if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { + throw new TypeError('session token version must be a non-negative safe integer'); + } + if (!Number.isSafeInteger(ttlSec) || ttlSec < 1) { + throw new RangeError('session lifetime must be a positive safe integer'); + } + if (ttlSec > MAX_SESSION_TTL_SECONDS) { + throw new RangeError(`session maximum lifetime is ${MAX_SESSION_TTL_SECONDS} seconds`); + } -export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { const now = Math.floor(Date.now() / 1000); const header = b64urlJson({ alg: 'HS256', typ: 'JWT' }); const body = b64urlJson({ ...payload, iat: now, exp: now + ttlSec }); @@ -55,16 +132,57 @@ export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { return `${header}.${body}.${sig}`; } +/** + * Verify a signed ScopeWeave session JWT and enforce database-backed revocation. + * + * The verifier recomputes an HS256 signature before parsing claims, then requires + * the signed header to declare the same pinned algorithm and JWT type. Session + * claims must contain a positive safe-integer subject, a future safe-integer + * expiry, and a non-negative safe-integer token version. The referenced user must + * exist and the token version must equal the current database value. Every + * session-JWT transport uses this function so `logout-all` cannot be bypassed by + * calendar, SSE, attachment-view, or bearer-token routes. + * + * @param {unknown} token - Compact JWT supplied by a client. + * @returns {Record} Verified session claims. + * @throws {Error} If structure, signature, header, claims, expiry, user, or revocation checks fail. + */ export function verifyToken(token) { const parts = String(token || '').split('.'); if (parts.length !== 3) throw new Error('malformed token'); const [header, body, sig] = parts; - // Recompute HS256 signature; never read/trust the header's declared alg. + + // Recompute HS256 first; do not parse or trust attacker-controlled claims + // before the compact representation has authenticated successfully. const expected = createHmac('sha256', SECRET).update(`${header}.${body}`).digest('base64url'); - const a = Buffer.from(sig); - const b = Buffer.from(expected); - if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad signature'); + const actualSignature = Buffer.from(sig); + const expectedSignature = Buffer.from(expected); + if ( + actualSignature.length !== expectedSignature.length + || !timingSafeEqual(actualSignature, expectedSignature) + ) { + throw new Error('bad signature'); + } + + const headerClaims = JSON.parse(Buffer.from(header, 'base64url').toString()); + if (!isClaimsObject(headerClaims)) throw new Error('invalid token header'); + if (headerClaims.alg !== 'HS256') throw new Error('invalid token algorithm'); + if (headerClaims.typ !== 'JWT') throw new Error('invalid token type'); + const payload = JSON.parse(Buffer.from(body, 'base64url').toString()); - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('expired'); + if (!isClaimsObject(payload)) throw new Error('invalid session claims'); + if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { + throw new Error('invalid session subject'); + } + if (!Number.isSafeInteger(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) { + throw new Error('expired or invalid session expiry'); + } + if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { + throw new Error('invalid token version'); + } + + const user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!user) throw new Error('unknown session subject'); + if (payload.tv !== user.token_version) throw new Error('revoked session'); return payload; } From 36374680fbcda88dc86877b7ccfcd365b3f0db04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:52:19 +0900 Subject: [PATCH 2/5] docs(doctoring): record strict session revocation boundary --- docs/doctoring/session-revocation.md | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/doctoring/session-revocation.md diff --git a/docs/doctoring/session-revocation.md b/docs/doctoring/session-revocation.md new file mode 100644 index 00000000..d162e8f7 --- /dev/null +++ b/docs/doctoring/session-revocation.md @@ -0,0 +1,82 @@ +# Session JWT revocation: evidence and design record + +## Decision + +Every ScopeWeave transport that accepts a general session JWT uses one +fail-closed verifier. Bearer middleware, calendar feeds, server-sent events, and +attachment-view routes therefore share signature, header, claim, subject, expiry, +and database-backed revocation checks. + +The implementation: + +1. pins the compact token to an authenticated `HS256` signature and signed `JWT` + type; +2. authenticates the compact representation before interpreting the JOSE header + or claim set; +3. requires a non-array claims object, positive safe-integer subject, future + safe-integer expiry, and non-negative safe-integer token version; +4. requires the subject to exist and compares the signed token version exactly + with the current persisted version; +5. rejects malformed, forged, expired, missing-user, and stale sessions before + tenant or resource lookup; +6. caps general session minting at seven days and rejects fractional, unsafe, + non-positive, or longer lifetimes; and +7. reserves narrower and shorter authority for the opaque access-grant design in + issue #413 rather than overloading the general session JWT. + +## Standards rationale + +RFC 7519 defines a JWT claims set as a JSON object and defines `sub` and `exp` as +registered claims. ScopeWeave narrows those flexible JSON representations to +safe integers because its database identifiers and token-version comparisons are +integer security boundaries. + +RFC 8725 requires callers to perform algorithm verification, validate every +cryptographic operation, use explicit typing for new JWT uses, and apply mutually +exclusive validation rules where different token kinds coexist. ScopeWeave pins +one algorithm and one type for general sessions and does not reuse this JWT +contract for the scoped URL grants planned in issue #413. + +RFC 6750 explains that any holder of a bearer token can exercise its authority, +recommends short-lived and audience-scoped credentials, and warns against page +URL transport because browser history and server logs can expose tokens. RFC +9700 updates OAuth security best current practice and prohibits clients from +passing access tokens in URI query parameters. This pull request does not claim +to remove the existing URL transport; it makes revocation and validation +consistent until issue #413 replaces those general credentials with narrowly +scoped opaque grants and separately revocable calendar subscription secrets. + +## Verification contract + +Regression tests must prove: + +- the signer rejects invalid subject, token version, fractional lifetime, + numerically unsafe lifetime, and any general-session lifetime over seven days; +- malformed compact tokens, signatures, JOSE headers, claim-set shapes, subjects, + expiries, and token-version values fail across every transport; +- a correctly signed token for a nonexistent subject fails before resource + lookup; +- two independently minted device sessions work before revocation; +- `logout-all` invalidates both stale sessions on bearer, calendar, SSE, and + attachment-view paths; and +- the replacement session continues through the same authentication boundary. + +All changed production helpers require complete JSDoc and 100% statement, +branch, function, and line coverage before the pull request can leave Draft. + +## References + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC +7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: +Bearer token usage* (RFC 6750). Internet Engineering Task Force. +https://doi.org/10.17487/RFC6750 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task +Force. https://doi.org/10.17487/RFC9700 + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current +practices* (BCP 225; RFC 8725). Internet Engineering Task Force. +https://doi.org/10.17487/RFC8725 From 0d785c009473ffe72e3df890f71411fcc31eae76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:53:25 +0900 Subject: [PATCH 3/5] test(security): prove strict revocation across JWT transports --- tests/api/session-revocation.test.mjs | 208 ++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/api/session-revocation.test.mjs diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs new file mode 100644 index 00000000..6164798b --- /dev/null +++ b/tests/api/session-revocation.test.mjs @@ -0,0 +1,208 @@ +// Security invariant: logout-all revocation and strict session-claim validation +// must apply uniformly to every JWT transport. Calendar clients and EventSource +// cannot reliably send Authorization headers, so query-token routes must share +// the same fail-closed verifier as bearer middleware. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; + +const JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = JWT_SECRET; + +const { app } = await import('../../server/app.mjs'); +const { signToken } = await import('../../server/auth.mjs'); + +const req = (path, opts = {}) => + app.request(path, { + ...opts, + headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, + }); +const body = (value) => JSON.stringify(value); +const encodeSegment = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * Create a correctly signed but intentionally unvalidated compact JWT. + * + * Production code cannot mint malformed session claims through `signToken`. + * This test-only signer is therefore required to exercise the verifier's + * hostile-input boundary without weakening the production signer. + * + * @param {unknown} payload - Raw signed payload value. + * @param {unknown} [headerClaims] - Raw signed header value. + * @returns {string} Compact HS256 token signed with the test secret. + */ +function signUnsafe( + payload, + headerClaims = { alg: 'HS256', typ: 'JWT' }, +) { + const header = encodeSegment(headerClaims); + const encodedBody = encodeSegment(payload); + const signature = createHmac('sha256', JWT_SECRET) + .update(`${header}.${encodedBody}`) + .digest('base64url'); + return `${header}.${encodedBody}.${signature}`; +} + +async function expectStreamStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/stream?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); + await response.body?.cancel?.(); +} + +async function expectCalendarStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/calendar.ics?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); +} + +async function expectAttachmentViewStatus(projectId, token, status, message) { + const response = await req( + `/api/projects/${projectId}/attachments/missing/view?token=${encodeURIComponent(token)}`, + ); + assert.equal(response.status, status, message); +} + +async function expectBearerStatus(token, status, message) { + const response = await req('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, status, message); +} + +/** + * Assert that one invalid session token is rejected by every supported transport. + * + * @param {number} projectId - Accessible project used by URL-token routes. + * @param {string} token - Invalid or revoked compact JWT. + * @param {string} label - Diagnostic label for assertion messages. + * @returns {Promise} Resolves after all four transport assertions. + */ +async function expectRejectedEverywhere(projectId, token, label) { + await expectBearerStatus(token, 401, `bearer rejects ${label}`); + await expectCalendarStatus(projectId, token, 401, `calendar rejects ${label}`); + await expectStreamStatus(projectId, token, 401, `SSE rejects ${label}`); + await expectAttachmentViewStatus(projectId, token, 401, `attachment view rejects ${label}`); +} + +test('session signer rejects malformed claims before minting a token', () => { + assert.throws(() => signToken(null), /claims must be an object/); + assert.throws(() => signToken([], 60), /claims must be an object/); + assert.throws(() => signToken({ sub: '1', tv: 0 }), /subject/); + assert.throws(() => signToken({ sub: 0, tv: 0 }), /subject/); + assert.throws( + () => signToken({ sub: Number.MAX_SAFE_INTEGER + 1, tv: 0 }), + /subject/, + ); + assert.throws(() => signToken({ sub: 1, tv: '0' }), /token version/); + assert.throws(() => signToken({ sub: 1, tv: -1 }), /token version/); + assert.throws( + () => signToken({ sub: 1, tv: Number.MAX_SAFE_INTEGER + 1 }), + /token version/, + ); + assert.throws(() => signToken({ sub: 1, tv: 0 }, '60'), /lifetime/); + assert.throws(() => signToken({ sub: 1, tv: 0 }, 0), /lifetime/); + assert.throws(() => signToken({ sub: 1, tv: 0 }, 1.5), /lifetime/); + assert.throws( + () => signToken({ sub: 1, tv: 0 }, 60 * 60 * 24 * 7 + 1), + /maximum lifetime/, + ); + assert.throws( + () => signToken({ sub: 1, tv: 0 }, Number.MAX_SAFE_INTEGER), + /maximum lifetime/, + ); +}); + +test('logout-all and strict JWT validation cover every session transport', async () => { + let response = await req('/api/auth/signup', { + method: 'POST', + body: body({ + email: 'revocation-test@scopeweave.test', + password: 'password123', + name: 'Revocation Test', + }), + }); + assert.equal(response.status, 200, 'signup succeeds'); + const tokenA = (await response.json()).token; + + const authA = { authorization: `Bearer ${tokenA}` }; + response = await req('/api/me', { headers: authA }); + assert.equal(response.status, 200, 'current session resolves the user'); + const userId = (await response.json()).user.id; + + response = await req('/api/projects', { + method: 'POST', + headers: authA, + body: body({ name: 'Revocation Probe' }), + }); + assert.equal(response.status, 200, 'project creation succeeds'); + const projectId = (await response.json()).id; + + response = await req('/api/auth/login', { + method: 'POST', + body: body({ + email: 'revocation-test@scopeweave.test', + password: 'password123', + }), + }); + assert.equal(response.status, 200, 'second-device login succeeds'); + const tokenB = (await response.json()).token; + + const now = Math.floor(Date.now() / 1000); + const validClaims = { sub: userId, tv: 0, iat: now, exp: now + 3_600 }; + const malformedTokens = [ + ['malformed compact token', 'not-a-jwt'], + ['invalid signature', `${tokenA.split('.').slice(0, 2).join('.')}.x`], + ['array header', signUnsafe(validClaims, [])], + ['non-HS256 header', signUnsafe(validClaims, { alg: 'none', typ: 'JWT' })], + ['non-JWT type', signUnsafe(validClaims, { alg: 'HS256', typ: 'JWS' })], + ['array claims', signUnsafe([])], + ['missing subject', signUnsafe({ tv: 0, iat: now, exp: now + 3_600 })], + ['string subject', signUnsafe({ ...validClaims, sub: '1' })], + ['zero subject', signUnsafe({ ...validClaims, sub: 0 })], + ['missing expiry', signUnsafe({ sub: userId, tv: 0, iat: now })], + ['string expiry', signUnsafe({ ...validClaims, exp: String(now + 3_600) })], + ['expired claim', signUnsafe({ ...validClaims, exp: now })], + ['missing token version', signUnsafe({ sub: userId, iat: now, exp: now + 3_600 })], + ['null token version', signUnsafe({ ...validClaims, tv: null })], + ['boolean token version', signUnsafe({ ...validClaims, tv: false })], + ['string token version', signUnsafe({ ...validClaims, tv: '0' })], + ['fractional token version', signUnsafe({ ...validClaims, tv: 0.5 })], + ['negative token version', signUnsafe({ ...validClaims, tv: -1 })], + ['unsafe token version', signUnsafe({ ...validClaims, tv: Number.MAX_SAFE_INTEGER + 1 })], + ]; + for (const [label, malformedToken] of malformedTokens) { + await expectRejectedEverywhere(projectId, malformedToken, label); + } + + const missingUserToken = signToken({ sub: userId + 1_000_000, tv: 0 }); + await expectRejectedEverywhere(projectId, missingUserToken, 'signed token for a missing user'); + + await expectBearerStatus(tokenA, 200, 'bearer accepts token A before revocation'); + await expectBearerStatus(tokenB, 200, 'bearer accepts token B before revocation'); + await expectCalendarStatus(projectId, tokenA, 200, 'calendar accepts token A before revocation'); + await expectCalendarStatus(projectId, tokenB, 200, 'calendar accepts token B before revocation'); + await expectStreamStatus(projectId, tokenA, 200, 'SSE accepts token A before revocation'); + await expectStreamStatus(projectId, tokenB, 200, 'SSE accepts token B before revocation'); + await expectAttachmentViewStatus(projectId, tokenA, 404, 'attachment view authenticates token A before lookup'); + await expectAttachmentViewStatus(projectId, tokenB, 404, 'attachment view authenticates token B before lookup'); + + response = await req('/api/auth/logout-all', { + method: 'POST', + headers: authA, + }); + assert.equal(response.status, 200, 'logout-all succeeds'); + const freshToken = (await response.json()).token; + + for (const [label, staleToken] of [['A', tokenA], ['B', tokenB]]) { + await expectRejectedEverywhere(projectId, staleToken, `stale token ${label}`); + } + + await expectBearerStatus(freshToken, 200, 'bearer accepts replacement token'); + await expectCalendarStatus(projectId, freshToken, 200, 'calendar accepts replacement token'); + await expectStreamStatus(projectId, freshToken, 200, 'SSE accepts replacement token'); + await expectAttachmentViewStatus(projectId, freshToken, 404, 'attachment view accepts replacement token before lookup'); +}); From 3a40a8e6f83f3055156341ba2a93b9b5e8317075 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:53:53 +0900 Subject: [PATCH 4/5] test(security): include session revocation API contract --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3caf0222..46d07bfb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", From 4790878f2189cdf16148d7eaefcf075417a90d6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:54:35 +0900 Subject: [PATCH 5/5] docs(changelog): record strict cross-transport session revocation --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0755faec..787ee51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 so network details and downstream response text cannot reach browser or diagnostic payloads; rejected unknown or whitespace-padded conversion states and malformed, unsupported-scheme, or HTTPS-downgrade artifact links. +- Centralized session JWT verification and database-backed `token_version` + revocation across bearer middleware, calendar feeds, server-sent events, and + attachment-view URL transports. +- Made session-token minting fail closed unless the subject, token version, and + lifetime are bounded safe integers, and capped general session lifetime at + seven days so internal callers cannot mint excessive or numerically unsafe + credentials. +- Rejected signed session JWTs with a non-HS256/JWT header, non-object claims, + missing or invalid subject/expiry, or a missing, Boolean, fractional, + negative, unsafe, or otherwise invalid token-version claim before user lookup. +- Added cross-device regression coverage proving that `logout-all` rejects stale + tokens on bearer, calendar, SSE, and attachment-view transports while the + replacement token continues through the same authentication boundary. ### Changed