Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 82 additions & 0 deletions docs/doctoring/session-revocation.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 131 additions & 13 deletions server/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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_<random>. 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');
}
Expand All @@ -25,17 +42,32 @@ 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');
const hash = scryptSync(password, salt, 64).toString('hex');
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(':');
Expand All @@ -45,26 +77,112 @@ 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<string, unknown>} 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<string, unknown>} 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 });
const sig = createHmac('sha256', SECRET).update(`${header}.${body}`).digest('base64url');
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<string, unknown>} 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;
}
Loading