diff --git a/src/backend/core/http/expressAugmentation.ts b/src/backend/core/http/expressAugmentation.ts index 6defdbe86..b00c55858 100644 --- a/src/backend/core/http/expressAugmentation.ts +++ b/src/backend/core/http/expressAugmentation.ts @@ -35,40 +35,18 @@ declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { - /** - * Populated by the global auth probe when a valid token is - * attached to the request (Bearer header, auth_token body/query, - * session cookie, or socket handshake). Absent for anonymous - * requests — route-level gates decide whether to reject. - */ actor?: Actor; /** The raw token string, if one was presented and parsed. */ token?: string; - /** - * Set by the global auth probe when a token was extracted from - * the request but failed to resolve to an actor (bad signature, - * expired, references a deleted session/user/app, or has a - * legacy shape v2 can't authenticate). Lets `requireAuthGate` - * distinguish "no token" from "token present but invalid" and - * emit the legacy `token_auth_failed` error so old clients - * trigger their re-login flow. - */ tokenAuthFailed?: boolean; - /** - * Raw request body bytes, captured by the global JSON parser's - * `verify` callback. Available for any JSON request — needed by - * webhook handlers that verify an HMAC over the exact bytes the - * sender signed (e.g., AppStore prod webhooks, BroadcastService - * inter-instance hooks). - * - * Set only when the global JSON parser actually ran (request had - * `Content-Type: application/json` or one of the recognized - * JSON-as-text variants). For non-JSON requests it stays - * `undefined`. - */ + requiresReauth?: { + reason: 'token_v1' | 'session_revoked' | 'session_expired'; + auth_id?: string; + }; + rawBody?: Buffer; /** Parsed user-agent, populated by the global UA-parsing middleware. */ diff --git a/src/backend/core/http/middleware/authProbe.test.ts b/src/backend/core/http/middleware/authProbe.test.ts index c152ddec0..4f0995af9 100644 --- a/src/backend/core/http/middleware/authProbe.test.ts +++ b/src/backend/core/http/middleware/authProbe.test.ts @@ -34,27 +34,48 @@ import { createAuthProbe } from './authProbe'; // test wants. This is mocking at a real boundary (a service), which the // AGENTS.md guidance explicitly allows. +type AuthResultLike = + | { actor: Actor } + | { reauth: { reason: string; auth_id?: string } } + | { invalid: true }; + interface StubAuth { service: AuthService; seenTokens: string[]; - /** What the next call to authenticateFromToken should return. */ + /** Legacy setter — accepts the old Actor|null|'throw' shape. */ setNext: (next: Actor | null | 'throw') => void; + /** AUTH-4: set the full AuthResult to be returned by `authenticate()`. */ + setNextResult: (next: AuthResultLike | 'throw') => void; } const makeStubAuth = (defaultActor: Actor | null = null): StubAuth => { const seenTokens: string[] = []; - let nextResult: Actor | null | 'throw' = defaultActor; + let nextResult: AuthResultLike | 'throw' = defaultActor + ? { actor: defaultActor } + : { invalid: true }; const service = { - authenticateFromToken: async (token: string) => { + // AUTH-4 entry point used by the probe. + authenticate: async (token: string) => { seenTokens.push(token); if (nextResult === 'throw') throw new Error('verify failed'); return nextResult; }, + // Back-compat wrapper for callers that still want Actor | null. + authenticateFromToken: async (token: string) => { + seenTokens.push(token); + if (nextResult === 'throw') throw new Error('verify failed'); + return 'actor' in nextResult ? nextResult.actor : null; + }, } as unknown as AuthService; return { service, seenTokens, setNext: (n) => { + if (n === 'throw') nextResult = 'throw'; + else if (n === null) nextResult = { invalid: true }; + else nextResult = { actor: n }; + }, + setNextResult: (n) => { nextResult = n; }, }; @@ -522,6 +543,224 @@ describe('createAuthProbe — actor attachment + failure tracking', () => { }); }); +// ── AUTH-4: reauth signal + KV counters ───────────────────────────── + +describe('createAuthProbe — AUTH-4 reauth signal', () => { + /** Capture KV increments without a real store. */ + const makeKvStub = () => { + const calls: Array<{ + key: string; + pathAndAmountMap: Record; + }> = []; + return { + calls, + store: { + incr: async (args: { + key: string; + pathAndAmountMap: Record; + }) => { + calls.push(args); + return { res: {} as Record, usage: 0 }; + }, + }, + }; + }; + + it('sets requiresReauth and counts v1 + reauth.token_v1 for a legacy v1 token', async () => { + const stub = makeStubAuth(); + stub.setNextResult({ + reauth: { reason: 'token_v1', auth_id: 'u-legacy' }, + // Some legacy paths resolve an actor anyway (lazy-backfill). + // The probe must still set requiresReauth; gate emits 401. + actor: { user: { uuid: 'u-legacy' } }, + } as never); + const kv = makeKvStub(); + const probe = createAuthProbe({ + authService: stub.service, + kvStore: kv.store, + }); + const { req } = await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer v1-tok' } }), + ); + expect(req.requiresReauth).toEqual({ + reason: 'token_v1', + auth_id: 'u-legacy', + }); + // Both increments fire under the same day-bucketed key. + expect(kv.calls).toHaveLength(1); + expect(kv.calls[0].key).toMatch(/^auth-v2:metrics:\d{4}-\d{2}-\d{2}$/); + expect(kv.calls[0].pathAndAmountMap).toEqual({ + v1: 1, + 'reauth.token_v1': 1, + }); + }); + + it('sets requiresReauth and counts reauth.session_revoked', async () => { + const stub = makeStubAuth(); + stub.setNextResult({ + reauth: { reason: 'session_revoked', auth_id: 'u-1' }, + }); + const kv = makeKvStub(); + const probe = createAuthProbe({ + authService: stub.service, + kvStore: kv.store, + }); + const { req } = await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer tok' } }), + ); + expect(req.requiresReauth?.reason).toBe('session_revoked'); + expect(kv.calls[0].pathAndAmountMap).toEqual({ + v1: 1, + 'reauth.session_revoked': 1, + }); + }); + + it('sets requiresReauth and counts reauth.session_expired', async () => { + const stub = makeStubAuth(); + stub.setNextResult({ + reauth: { reason: 'session_expired', auth_id: 'u-1' }, + }); + const kv = makeKvStub(); + const probe = createAuthProbe({ + authService: stub.service, + kvStore: kv.store, + }); + const { req } = await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer tok' } }), + ); + expect(req.requiresReauth?.reason).toBe('session_expired'); + expect(kv.calls[0].pathAndAmountMap).toEqual({ + v1: 1, + 'reauth.session_expired': 1, + }); + }); + + it('counts v2 verify (no reauth) for a healthy token', async () => { + const stub = makeStubAuth({ user: { uuid: 'u-1' } }); + const kv = makeKvStub(); + const probe = createAuthProbe({ + authService: stub.service, + kvStore: kv.store, + }); + const { req } = await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer good-tok' } }), + ); + expect(req.actor).toBeTruthy(); + expect(req.requiresReauth).toBeUndefined(); + expect(kv.calls[0].pathAndAmountMap).toEqual({ v2: 1 }); + }); + + it('does not increment counters when no token is presented', async () => { + const stub = makeStubAuth(); + const kv = makeKvStub(); + const probe = createAuthProbe({ + authService: stub.service, + kvStore: kv.store, + }); + await runProbe(probe, makeReq({})); + // Anonymous-OK routes pay no metrics cost. + expect(kv.calls).toHaveLength(0); + }); + + it('absorbs KV failures — never rejects on the hot path', async () => { + const stub = makeStubAuth({ user: { uuid: 'u-1' } }); + // Failing KV: increments throw. Probe must still succeed. + const failingKv = { + incr: async () => { + throw new Error('kv unavailable'); + }, + }; + const probe = createAuthProbe({ + authService: stub.service, + kvStore: failingKv, + }); + const { req, next } = await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer good-tok' } }), + ); + expect(next).toHaveBeenCalledWith(); + expect(req.actor).toBeTruthy(); + }); + + it('works without a kvStore (counters become no-ops)', async () => { + const stub = makeStubAuth({ user: { uuid: 'u-1' } }); + // Production may not always pass a KV — fresh installs without + // dynamo wired still need a functioning probe. + const probe = createAuthProbe({ authService: stub.service }); + const { req } = await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer good-tok' } }), + ); + expect(req.actor).toBeTruthy(); + expect(req.requiresReauth).toBeUndefined(); + }); + + it('logs `[auth-v2] reauth reason= auth_id=` per event', async () => { + // The log line is the human-facing forensic counterpart to the + // KV counter. Asserting the exact shape so ops can `grep + // '\[auth-v2\] reauth'` and trust the format won't drift. + const stub = makeStubAuth(); + stub.setNextResult({ + reauth: { reason: 'session_revoked', auth_id: 'u-grep' }, + }); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + try { + const probe = createAuthProbe({ authService: stub.service }); + await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer tok' } }), + ); + expect(infoSpy).toHaveBeenCalledWith( + '[auth-v2] reauth reason=session_revoked auth_id=u-grep', + ); + } finally { + infoSpy.mockRestore(); + } + }); + + it('logs `auth_id=-` when the reauth result has no auth_id', async () => { + const stub = makeStubAuth(); + stub.setNextResult({ + reauth: { reason: 'session_expired' }, + }); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + try { + const probe = createAuthProbe({ authService: stub.service }); + await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer tok' } }), + ); + expect(infoSpy).toHaveBeenCalledWith( + '[auth-v2] reauth reason=session_expired auth_id=-', + ); + } finally { + infoSpy.mockRestore(); + } + }); + + it('does not emit a reauth log line on a healthy v2 verify', async () => { + const stub = makeStubAuth({ user: { uuid: 'u-1' } }); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + try { + const probe = createAuthProbe({ authService: stub.service }); + await runProbe( + probe, + makeReq({ headers: { authorization: 'Bearer good-tok' } }), + ); + const reauthCalls = infoSpy.mock.calls.filter((args) => + String(args[0]).startsWith('[auth-v2] reauth'), + ); + expect(reauthCalls).toHaveLength(0); + } finally { + infoSpy.mockRestore(); + } + }); +}); + // ── Server-backed end-to-end (real AuthService + DB) ──────────────── // // The unit tests above cover the extraction logic in isolation. This diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts index 98322df17..3b385456c 100644 --- a/src/backend/core/http/middleware/authProbe.ts +++ b/src/backend/core/http/middleware/authProbe.ts @@ -18,7 +18,11 @@ */ import type { Request, RequestHandler } from 'express'; -import type { AuthService } from '../../../services/auth/AuthService'; +import type { + AuthService, + ReauthReason, +} from '../../../services/auth/AuthService'; +import type { SystemKVStore } from '../../../stores/systemKv/SystemKVStore'; // Ensure the `Request.actor` / `Request.token` augmentation is in scope // wherever this middleware is imported. @@ -28,8 +32,26 @@ interface AuthProbeOptions { authService: AuthService; /** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */ cookieName?: string; + kvStore?: SystemKVStore; } +/** Day-bucketed KV key for AUTH-4 metrics. */ +const authV2MetricsKey = (): string => { + const now = new Date(); + const yyyy = now.getUTCFullYear(); + const mm = String(now.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(now.getUTCDate()).padStart(2, '0'); + return `auth-v2:metrics:${yyyy}-${mm}-${dd}`; +}; + +const bumpCounter = ( + kv: Pick | undefined, + pathAndAmountMap: Record, +): void => { + if (!kv) return; + kv.incr({ key: authV2MetricsKey(), pathAndAmountMap }).catch(() => {}); +}; + /** * Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on * every request, tries to locate a token in the usual places, and — if one @@ -49,7 +71,7 @@ interface AuthProbeOptions { * 6. Socket handshake query (for ws upgrades that pass through HTTP first) */ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => { - const { authService, cookieName } = opts; + const { authService, cookieName, kvStore } = opts; return async (req, _res, next): Promise => { // If something upstream already attached an actor, respect it. if (req.actor) { @@ -64,24 +86,31 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => { } try { - const actor = await authService.authenticateFromToken(token); - if (actor) { - req.actor = actor; + const result = await authService.authenticate(token); + + if (result.reauth) { + bumpCounter(kvStore, { + v1: 1, + [`reauth.${result.reauth.reason}`]: 1, + }); + req.requiresReauth = { + reason: result.reauth.reason as ReauthReason, + auth_id: result.reauth.auth_id, + }; + console.info( + `[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'}`, + ); + } else if (result.actor) { + bumpCounter(kvStore, { v2: 1 }); + } + + if (result.actor) { + req.actor = result.actor; req.token = token; - } else { - // Token was present but didn't resolve — covers v1-shape - // tokens v2 can't authenticate (e.g. FPE-encrypted session - // UUIDs), as well as tokens whose session/user/app rows - // have been deleted. `requireAuthGate` reads this flag - // to emit `token_auth_failed` (matching v1) so clients - // trigger their re-login flow instead of retrying. + } else if (result.invalid) { req.tokenAuthFailed = true; } } catch { - // Probe never rejects — invalid tokens just leave `req.actor` - // undefined. Same flag as above so the gate's response shape - // is identical regardless of whether the failure came from - // jwt.verify or a downstream lookup. req.tokenAuthFailed = true; } next(); diff --git a/src/backend/core/http/middleware/gates.test.ts b/src/backend/core/http/middleware/gates.test.ts index 335db1295..2ff9fc5a5 100644 --- a/src/backend/core/http/middleware/gates.test.ts +++ b/src/backend/core/http/middleware/gates.test.ts @@ -136,6 +136,53 @@ describe('requireAuthGate', () => { }); expectHttpError(got, 403, 'forbidden'); }); + + // ── AUTH-4 reauth signal ──────────────────────────────────────── + + it('returns 401 reauth_required for a legacy v1 token', () => { + const got = runGate(requireAuthGate(), { + requiresReauth: { reason: 'token_v1', auth_id: 'u-1' }, + }); + expectHttpError(got, 401, 'reauth_required'); + expect((got as HttpError).fields).toMatchObject({ + code: 'reauth_required', + reason: 'token_v1', + auth_id: 'u-1', + }); + }); + + it('returns 401 reauth_required with reason=session_revoked', () => { + const got = runGate(requireAuthGate(), { + requiresReauth: { reason: 'session_revoked', auth_id: 'u-2' }, + }); + expectHttpError(got, 401, 'reauth_required'); + expect((got as HttpError).fields).toMatchObject({ + reason: 'session_revoked', + auth_id: 'u-2', + }); + }); + + it('returns 401 reauth_required with reason=session_expired', () => { + const got = runGate(requireAuthGate(), { + requiresReauth: { reason: 'session_expired' }, + }); + expectHttpError(got, 401, 'reauth_required'); + expect((got as HttpError).fields).toMatchObject({ + reason: 'session_expired', + }); + // No auth_id field at all when none was supplied (vs. set-to-undefined). + expect((got as HttpError).fields?.auth_id).toBeUndefined(); + }); + + it('reauth_required takes priority over tokenAuthFailed', () => { + // Both flags set: the structured reauth signal wins. v2 clients + // key on `code === 'reauth_required'`; v1 clients still see a 401. + const got = runGate(requireAuthGate(), { + requiresReauth: { reason: 'token_v1', auth_id: 'u-1' }, + tokenAuthFailed: true, + }); + expectHttpError(got, 401, 'reauth_required'); + }); }); // ── requireUserActorGate ──────────────────────────────────────────── diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index 515c653c9..2a89a90e3 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -24,14 +24,19 @@ import { assertVerifiedEmail } from '../verifiedEmail'; // Make sure the `Express.Request.actor` augmentation is in scope. import '../expressAugmentation'; -/** - * Build the 401 a route gate emits when `req.actor` is absent. Splits the - * legacy code so old clients can tell the two cases apart: `token_missing` - * means "send a token", `token_auth_failed` means "your token didn't work, - * re-login". Matches the v1 backend's auth-failure shape so the existing - * client retry-vs-reauth logic doesn't need to change. - */ const rejectAuth = (req: Request): HttpError => { + if (req.requiresReauth) { + return new HttpError(401, 'Re-authentication required', { + legacyCode: 'reauth_required', + fields: { + code: 'reauth_required', + reason: req.requiresReauth.reason, + ...(req.requiresReauth.auth_id + ? { auth_id: req.requiresReauth.auth_id } + : {}), + }, + }); + } if (req.tokenAuthFailed) { return new HttpError(401, 'Authentication failed', { legacyCode: 'token_auth_failed', diff --git a/src/backend/server.ts b/src/backend/server.ts index e8159e22a..7b2ec316b 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -471,6 +471,7 @@ export class PuterServer { createAuthProbe({ authService, cookieName: this.#config.cookie_name, + kvStore: this.stores.kv, }), ); } diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 9a2137dcb..295afc38e 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import jwt from 'jsonwebtoken'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import type { Actor } from '../../core/actor.js'; @@ -166,6 +167,230 @@ describe('AuthService (integration)', () => { }); }); + // ── AUTH-4: rich `authenticate()` result shape ─────────────────── + + describe('authenticate (AUTH-4 reauth signal)', () => { + it('returns { actor } for a healthy v2 session token', async () => { + const user = await makeUser(); + const { token } = await authService.createSessionToken(user, {}); + const result = await authService.authenticate(token); + expect(result.actor?.user.uuid).toBe(user.uuid); + expect(result.reauth).toBeUndefined(); + expect(result.invalid).toBeUndefined(); + }); + + it('returns { reauth: session_revoked } when the row is soft-revoked', async () => { + const user = await makeUser(); + const { token, session } = await authService.createSessionToken( + user, + {}, + ); + await server.stores.session.removeByUuid( + (session as { uuid: string }).uuid, + ); + const result = await authService.authenticate(token); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toEqual({ + reason: 'session_revoked', + auth_id: user.uuid, + }); + }); + + it('returns { reauth: session_expired } when expires_at is in the past', async () => { + const user = await makeUser(); + const { token, session } = await authService.createSessionToken( + user, + {}, + ); + // Backdate expires_at directly — the mint path sets it + // 30d in the future, so we have to forcibly age it for the + // test. + await server.clients.db.write( + 'UPDATE `sessions` SET `expires_at` = ? WHERE `uuid` = ?', + [ + Math.floor(Date.now() / 1000) - 60, + (session as { uuid: string }).uuid, + ], + ); + // Invalidate the cached row so getByUuidAny re-reads from DB. + await server.clients.redis.del( + `sessions:v2:uuid:${(session as { uuid: string }).uuid}`, + ); + const result = await authService.authenticate(token); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toEqual({ + reason: 'session_expired', + auth_id: user.uuid, + }); + }); + + it('returns { invalid } for a malformed token', async () => { + const result = await authService.authenticate('not-a-jwt'); + expect(result.invalid).toBe(true); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toBeUndefined(); + }); + + it('overlays reauth.token_v1 onto the actor when the token verifies via the legacy secret', async () => { + // Hand-mint a v1-shape token with the legacy secret (no kid). + // Compress the same way TokenService does so it round-trips + // after verify. + const user = await makeUser(); + const { session } = await authService.createSessionToken(user, {}); + const legacyJwt = jwt.sign( + { + // v1 compression: type=session → t='s', uuid → u, user_uid → uu + t: 's', + u: Buffer.from( + (session as { uuid: string }).uuid.replace(/-/g, ''), + 'hex', + ).toString('base64'), + uu: Buffer.from( + user.uuid.replace(/-/g, ''), + 'hex', + ).toString('base64'), + }, + 'dev-jwt-secret-change-me', + ); + const result = await authService.authenticate(legacyJwt); + // The actor is resolved (row exists and is active) AND the + // reauth signal fires so SDK clients migrate. + expect(result.actor?.user.uuid).toBe(user.uuid); + expect(result.reauth?.reason).toBe('token_v1'); + }); + + // ── App-under-user verify path ───────────────────────────── + + // Helper: insert a minimal app row so the verify path's + // `stores.app.getByUid(decoded.app_uid)` lookup succeeds. Without + // a real row the verify falls through to `{ invalid: true }` + // before it ever checks the session state we want to test. + const makeApp = async (): Promise => { + const uid = `app-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [ + uid, + `n-${uid}`, + `t-${uid}`, + `https://${uid}.example/`, + 1, + ], + ); + return uid; + }; + + it('app-under-user: returns reauth.session_revoked when the app session is revoked', async () => { + const user = await makeUser(); + const appUid = await makeApp(); + const appToken = await authService.getUserAppToken( + { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor, + appUid, + ); + // Pull the session_uid claim out of the JWT so we revoke + // the exact row the verify path will look up. + const decoded = server.services.token.verify( + 'auth', + appToken, + ) as { session_uid: string }; + await server.stores.session.removeByUuid(decoded.session_uid); + + const result = await authService.authenticate(appToken); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toEqual({ + reason: 'session_revoked', + auth_id: user.uuid, + }); + }); + + it('app-under-user: returns reauth.session_expired when the app session expires_at is in the past', async () => { + const user = await makeUser(); + const appUid = await makeApp(); + const appToken = await authService.getUserAppToken( + { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor, + appUid, + ); + const decoded = server.services.token.verify( + 'auth', + appToken, + ) as { session_uid: string }; + await server.clients.db.write( + 'UPDATE `sessions` SET `expires_at` = ? WHERE `uuid` = ?', + [Math.floor(Date.now() / 1000) - 60, decoded.session_uid], + ); + await server.clients.redis.del( + `sessions:v2:uuid:${decoded.session_uid}`, + ); + + const result = await authService.authenticate(appToken); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toEqual({ + reason: 'session_expired', + auth_id: user.uuid, + }); + }); + + // ── Access-token verify path ─────────────────────────────── + + it('access-token: returns reauth.session_revoked when the access-token session is revoked', async () => { + const user = await makeUser(); + const accessToken = await authService.createAccessToken( + { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor, + [['fs:abc:read']], + ); + const decoded = server.services.token.verify( + 'auth', + accessToken, + ) as { session_uid: string }; + await server.stores.session.removeByUuid(decoded.session_uid); + + const result = await authService.authenticate(accessToken); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toEqual({ + reason: 'session_revoked', + auth_id: user.uuid, + }); + }); + + it('access-token: returns reauth.session_expired when the access-token session expires_at is in the past', async () => { + const user = await makeUser(); + // Pass a short expiresIn so the row gets a non-NULL + // expires_at to start with — the verify path's expired-row + // check only fires when expires_at is non-NULL. + const accessToken = await authService.createAccessToken( + { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor, + [['fs:abc:read']], + { expiresIn: '1h' }, + ); + const decoded = server.services.token.verify( + 'auth', + accessToken, + ) as { session_uid: string }; + await server.clients.db.write( + 'UPDATE `sessions` SET `expires_at` = ? WHERE `uuid` = ?', + [Math.floor(Date.now() / 1000) - 60, decoded.session_uid], + ); + await server.clients.redis.del( + `sessions:v2:uuid:${decoded.session_uid}`, + ); + + const result = await authService.authenticate(accessToken); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toEqual({ + reason: 'session_expired', + auth_id: user.uuid, + }); + }); + }); + describe('createSessionToken / createGuiToken / createSessionTokenForSession', () => { it('creates a session and signs verifiable session+GUI tokens', async () => { const user = await makeUser(); diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 2408f48e0..9ee0c7d0e 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -41,6 +41,14 @@ const APP_ORIGIN_UUID_NAMESPACE = '33de3768-8ee0-43e9-9e73-db192b97a5d8'; const nowSeconds = (): number => Math.floor(Date.now() / 1000); +export type ReauthReason = 'token_v1' | 'session_revoked' | 'session_expired'; + +export interface AuthResult { + actor?: Actor; + reauth?: { reason: ReauthReason; auth_id?: string }; + invalid?: true; +} + /** * Authentication service. * @@ -75,15 +83,12 @@ export class AuthService extends PuterService { // ── Public API ────────────────────────────────────────────────── - /** - * Resolve an auth token to a v2 Actor. - * - * Returns `null` for *any* failure — invalid signature, malformed payload, - * legacy token shape, missing session, missing user, missing app. The - * caller (auth probe) never differentiates: it either attaches an actor - * or leaves `req.actor` undefined for per-route gates to reject. - */ async authenticateFromToken(token: string): Promise { + const result = await this.authenticate(token); + return result.actor ?? null; + } + + async authenticate(token: string): Promise { let decoded: AnyTokenPayload; try { decoded = this.services.token.verify( @@ -91,23 +96,36 @@ export class AuthService extends PuterService { token, ); } catch { - return null; + return { invalid: true }; } // Legacy tokens (pre-`type` field) aren't supported. - if (!decoded.type) return null; + if (!decoded.type) return { invalid: true }; + let result: AuthResult; switch (decoded.type) { case 'session': case 'gui': - return this.#actorFromSessionToken(decoded); + result = await this.#actorFromSessionToken(decoded); + break; case 'app-under-user': - return this.#actorFromAppUnderUserToken(decoded); + result = await this.#actorFromAppUnderUserToken(decoded); + break; case 'access-token': - return this.#actorFromAccessTokenToken(decoded); + result = await this.#actorFromAccessTokenToken(decoded); + break; default: - return null; + return { invalid: true }; } + + if (decoded.legacy && !result.reauth) { + const authId = + (decoded.auth_id as string | undefined) ?? + result.actor?.user?.uuid; + result.reauth = { reason: 'token_v1', auth_id: authId }; + } + + return result; } // ── Session lifecycle ──────────────────────────────────────────── @@ -1010,77 +1028,83 @@ export class AuthService extends PuterService { async #actorFromSessionToken( decoded: SessionTokenPayload, - ): Promise { + ): Promise { const user = await this.stores.user.getByUuid(decoded.user_uid); - if (!user) return null; + if (!user) return { invalid: true }; + const auth_id = this.#authIdFor(user as UserRow); // v2 tokens prefer `session_uid`; v1 only carries `uuid`. Both // store the web-session uuid. const sessionUuid = decoded.session_uid ?? decoded.uuid; - let session: SessionRow | null = sessionUuid - ? ((await this.stores.session.getByUuid( + const rawRow = sessionUuid + ? ((await this.stores.session.getByUuidAny( sessionUuid, )) as SessionRow | null) : null; - // Legacy v1 tokens whose row never existed (or whose row was - // pre-PUT-1013) get lazy-backfilled so revoke works during the - // migration window. AUTH-4 will still emit `reauth_required` - // for these, but until then the session validates. + if (rawRow?.revoked_at != null) { + return { reauth: { reason: 'session_revoked', auth_id } }; + } + if (rawRow?.expires_at != null && rawRow.expires_at <= nowSeconds()) { + return { reauth: { reason: 'session_expired', auth_id } }; + } + + let session: SessionRow | null = rawRow; + if (!session && decoded.legacy) { session = (await this.stores.session.findOrCreateLegacyWeb({ userId: user.id, - auth_id: this.#authIdFor(user as UserRow), + auth_id, })) as SessionRow | null; } - if (!session) return null; + if (!session) return { invalid: true }; this.stores.session .touch({ uuid: session.uuid, userId: user.id }) .catch(() => {}); - return this.#buildUserActor(user, session); + return { actor: this.#buildUserActor(user, session) }; } async #actorFromAppUnderUserToken( decoded: AppUnderUserTokenPayload, - ): Promise { + ): Promise { const user = await this.stores.user.getByUuid(decoded.user_uid); - if (!user) return null; + if (!user) return { invalid: true }; + const auth_id = this.#authIdFor(user as UserRow); const app = await this.stores.app.getByUid(decoded.app_uid); - if (!app) return null; + if (!app) return { invalid: true }; - // v2 tokens point at the app's own `kind='app'` session row via - // `session_uid`. v1 tokens (or v2 tokens minted before this - // landed) get lazy-backfilled to the same idempotent row keyed - // on (user_id, app_uid). For decoded.legacy we always backfill; - // for decoded.session_uid we trust the row reference. - let session: SessionRow | null = null; + let rawRow: SessionRow | null = null; if (decoded.session_uid) { - session = (await this.stores.session.getByUuid( + rawRow = (await this.stores.session.getByUuidAny( decoded.session_uid, )) as SessionRow | null; } + + if (rawRow?.revoked_at != null) { + return { reauth: { reason: 'session_revoked', auth_id } }; + } + if (rawRow?.expires_at != null && rawRow.expires_at <= nowSeconds()) { + return { reauth: { reason: 'session_expired', auth_id } }; + } + + let session: SessionRow | null = rawRow; + + // Legacy v1: lazy-backfill keyed on (user_id, app_uid). if (!session && decoded.legacy) { session = (await this.stores.session.getOrCreateApp( user.id, decoded.app_uid, - { auth_id: this.#authIdFor(user as UserRow) }, + { auth_id }, )) as SessionRow | null; } - // v2 tokens whose session_uid is missing/revoked are rejected - // outright — the row is the authoritative kill switch. - if (!session && !decoded.legacy) return null; + if (!session && !decoded.legacy) return { invalid: true }; - // Pre-v2 fallback: v1 tokens that carried `decoded.session` - // (raw web-session uuid). Best-effort — if the row doesn't - // resolve we still proceed so old puter-js builds without the - // re-login patch don't strand users. AUTH-4 owns the migration - // pressure. if (!session && decoded.session) { session = (await this.stores.session.getByUuid( decoded.session, @@ -1091,50 +1115,50 @@ export class AuthService extends PuterService { .touch({ uuid: session?.uuid, userId: user.id }) .catch(() => {}); - return this.#buildAppUnderUserActor(user, app, session); + return { + actor: this.#buildAppUnderUserActor(user, app, session), + }; } async #actorFromAccessTokenToken( decoded: AccessTokenPayload, - ): Promise { - if (!decoded.token_uid || !decoded.user_uid) return null; + ): Promise { + if (!decoded.token_uid || !decoded.user_uid) return { invalid: true }; const user = await this.stores.user.getByUuid(decoded.user_uid); - if (!user) return null; + if (!user) return { invalid: true }; + const auth_id = this.#authIdFor(user as UserRow); - // v2 access tokens carry their session row uuid as `session_uid` - // and the row is the kill switch — reject if missing/revoked. - // v1 tokens lazy-backfill keyed on `token_uid` so revoke works - // during the migration window. let session: SessionRow | null = null; if (decoded.session_uid) { - session = (await this.stores.session.getByUuid( + const rawRow = (await this.stores.session.getByUuidAny( decoded.session_uid, )) as SessionRow | null; - if (!session) return null; + if (rawRow?.revoked_at != null) { + return { reauth: { reason: 'session_revoked', auth_id } }; + } + if ( + rawRow?.expires_at != null && + rawRow.expires_at <= nowSeconds() + ) { + return { reauth: { reason: 'session_expired', auth_id } }; + } + if (!rawRow) return { invalid: true }; + session = rawRow; } else if (decoded.legacy) { session = (await this.stores.session.findOrCreateLegacyAccessToken( decoded.token_uid, - { - userId: user.id, - auth_id: this.#authIdFor(user as UserRow), - }, + { userId: user.id, auth_id }, )) as SessionRow | null; // If backfill fails (DB write contention etc.) we don't // strand the legacy token — it falls through to the // permission-table path that v1 used. } - // Otherwise: v2 token without `session_uid` shouldn't happen - // (the mint path always emits it), but if a malformed token - // reaches here we let it through with no session binding — - // the access_token_permissions table is the v1 contract. - // The authorizer is the identity whose permissions the access token - // can exercise — either a plain user or an app-under-user. let authorizer: Actor; if (decoded.app_uid) { const app = await this.stores.app.getByUid(decoded.app_uid); - if (!app) return null; + if (!app) return { invalid: true }; authorizer = this.#buildAppUnderUserActor(user, app, null); } else { authorizer = this.#buildUserActor(user, null); @@ -1147,11 +1171,13 @@ export class AuthService extends PuterService { } return { - user: this.#actorUserFromRow(user), - accessToken: { - uid: decoded.token_uid, - issuer: authorizer, - authorized: null, + actor: { + user: this.#actorUserFromRow(user), + accessToken: { + uid: decoded.token_uid, + issuer: authorizer, + authorized: null, + }, }, }; } diff --git a/src/backend/services/socket/SocketService.test.ts b/src/backend/services/socket/SocketService.test.ts new file mode 100644 index 000000000..8bd1a2b12 --- /dev/null +++ b/src/backend/services/socket/SocketService.test.ts @@ -0,0 +1,129 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import type { AuthResult } from '../auth/AuthService.js'; +import { + buildSocketReauthError, + decideSocketAuth, + type SocketReauthError, +} from './SocketService.js'; + +// ── buildSocketReauthError ────────────────────────────────────────── + +describe('buildSocketReauthError', () => { + it('packs reason + auth_id into error.data matching the HTTP shape', () => { + const err = buildSocketReauthError({ + reason: 'token_v1', + auth_id: 'u-1', + }); + expect(err.message).toBe('reauth_required'); + expect(err.data).toEqual({ + code: 'reauth_required', + reason: 'token_v1', + auth_id: 'u-1', + }); + }); + + it('omits auth_id when none was supplied', () => { + const err = buildSocketReauthError({ reason: 'session_expired' }); + expect(err.data).toEqual({ + code: 'reauth_required', + reason: 'session_expired', + }); + expect(err.data.auth_id).toBeUndefined(); + }); +}); + +// ── decideSocketAuth ──────────────────────────────────────────────── + +describe('decideSocketAuth', () => { + const userActor: Actor = { + user: { id: 1, uuid: 'u-1', username: 'u' }, + }; + const appActor: Actor = { + user: { id: 1, uuid: 'u-1', username: 'u' }, + app: { uid: 'app-1', id: 2 }, + }; + const accessTokenActor: Actor = { + user: { id: 1, uuid: 'u-1', username: 'u' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { id: 1, uuid: 'u-1', username: 'u' } }, + authorized: null, + }, + }; + + it('accepts a plain user actor', () => { + const decision = decideSocketAuth({ actor: userActor } as AuthResult); + expect(decision).toEqual({ accept: userActor }); + }); + + it('rejects with a structured reauth error when result carries reauth', () => { + const decision = decideSocketAuth({ + reauth: { reason: 'session_revoked', auth_id: 'u-1' }, + } as AuthResult); + if (!('reject' in decision)) throw new Error('expected reject'); + expect(decision.reject.message).toBe('reauth_required'); + expect((decision.reject as SocketReauthError).data).toEqual({ + code: 'reauth_required', + reason: 'session_revoked', + auth_id: 'u-1', + }); + }); + + it('rejects an app-under-user actor with a specific message', () => { + const decision = decideSocketAuth({ actor: appActor } as AuthResult); + if (!('reject' in decision)) throw new Error('expected reject'); + expect(decision.reject.message).toMatch(/only user tokens/); + // Plain Error — no structured `data` payload. + expect( + (decision.reject as { data?: unknown }).data, + ).toBeUndefined(); + }); + + it('rejects an access-token actor with a specific message', () => { + const decision = decideSocketAuth({ + actor: accessTokenActor, + } as AuthResult); + if (!('reject' in decision)) throw new Error('expected reject'); + expect(decision.reject.message).toMatch(/only user tokens/); + }); + + it('rejects when AuthService returned no actor at all', () => { + const decision = decideSocketAuth({ invalid: true } as AuthResult); + if (!('reject' in decision)) throw new Error('expected reject'); + expect(decision.reject.message).toBe('socket auth failed'); + }); + + it('reauth wins over a usable actor (legacy v1 path)', () => { + // Legacy v1 tokens may lazy-backfill a valid actor AND emit a + // reauth signal — the socket must still reject so the client + // migrates. Mirrors the HTTP gate's priority. + const decision = decideSocketAuth({ + actor: userActor, + reauth: { reason: 'token_v1', auth_id: 'u-1' }, + } as AuthResult); + if (!('reject' in decision)) throw new Error('expected reject'); + expect((decision.reject as SocketReauthError).data.reason).toBe( + 'token_v1', + ); + }); +}); diff --git a/src/backend/services/socket/SocketService.ts b/src/backend/services/socket/SocketService.ts index cf955d0da..2888656b1 100644 --- a/src/backend/services/socket/SocketService.ts +++ b/src/backend/services/socket/SocketService.ts @@ -22,9 +22,63 @@ import type { Server as HttpServer } from 'node:http'; import { Server as SocketIOServer, type Socket } from 'socket.io'; import type { Actor } from '../../core/actor.js'; import { isAccessTokenActor, isAppActor } from '../../core/actor.js'; -import type { AuthService } from '../auth/AuthService.js'; +import type { AuthResult, AuthService } from '../auth/AuthService.js'; import { PuterService } from '../types.js'; +/** + * Error carrying the AUTH-4 reauth payload. socket.io forwards `error.data` + * to the client's `connect_error` callback, so clients receive the same + * `{ code, reason, auth_id }` shape the HTTP gate emits. + */ +export type SocketReauthError = Error & { data: Record }; + +/** + * Build a `reauth_required` error for the socket auth middleware to + * pass to `next()`. Exported for unit testing. + */ +export const buildSocketReauthError = (reauth: { + reason: string; + auth_id?: string; +}): SocketReauthError => { + const err = new Error('reauth_required') as SocketReauthError; + err.data = { + code: 'reauth_required', + reason: reauth.reason, + ...(reauth.auth_id ? { auth_id: reauth.auth_id } : {}), + }; + return err; +}; + +/** Pure decision from an `AuthResult` to a socket-side accept/reject. */ +export type SocketAuthDecision = { accept: Actor } | { reject: Error }; + +/** + * Map an `AuthService.authenticate()` result onto the socket-handshake + * verdict. Order matters: + * + * 1. `reauth` → structured `reauth_required` error so the client can + * drive the same migration / re-login flow it does for HTTP. + * 2. Missing actor → generic `socket auth failed`. + * 3. App-under-user / access-token actor → rejected with a specific + * message; sockets only accept plain user actors. + * 4. Otherwise → accept the actor. + * + * Pure / no side effects — the middleware logs the reauth event. + */ +export const decideSocketAuth = (result: AuthResult): SocketAuthDecision => { + if (result.reauth) { + return { reject: buildSocketReauthError(result.reauth) }; + } + const actor = result.actor; + if (!actor || !actor.user) { + return { reject: new Error('socket auth failed') }; + } + if (isAppActor(actor) || isAccessTokenActor(actor)) { + return { reject: new Error('socket auth: only user tokens accepted') }; + } + return { accept: actor }; +}; + /** * Socket push target. A `room` fans to every socket in that room; a * `socket` targets one specific socket by id. Multiple specifiers may @@ -266,21 +320,27 @@ export class SocketService extends PuterService { } try { - const actor = await authService.authenticateFromToken(token); - if (!actor || !actor.user) { - next(new Error('socket auth failed')); - return; + const result = await authService.authenticate(token); + + // Log the AUTH-4 signal here; `decideSocketAuth` stays + // pure (no side effects) for unit testing. The `(ws)` + // suffix lets the same grep find HTTP + socket events. + if (result.reauth) { + console.info( + `[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'} (ws)`, + ); } - // Only user tokens accepted — no app-under-user, no access-token. - if (isAppActor(actor) || isAccessTokenActor(actor)) { - next(new Error('socket auth: only user tokens accepted')); + + const decision = decideSocketAuth(result); + if ('reject' in decision) { + next(decision.reject); return; } - socket.actor = actor; + socket.actor = decision.accept; // user.id is numeric in the DB; stringify for room name // so adapter lookups key on a stable type. - socket.join(String(actor.user.id)); + socket.join(String(decision.accept.user!.id)); next(); } catch (err) { console.warn('[socket] auth error', err); @@ -379,7 +439,7 @@ export class SocketService extends PuterService { this.clients.event.emit( `sent-to-user.${wireName}`, { - user_id: userId, + user_id: userId as number, response: data.response, }, {}, diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index 39d6b6705..e78ca075e 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -69,26 +69,27 @@ export class SessionStore extends PuterStore { * expired row in cache doesn't grant access. */ async getByUuid(uuid) { + const row = await this.getByUuidAny(uuid); + if (!row) return null; + if (row.revoked_at != null) return null; + if (isExpired(row, nowSeconds())) return null; + return row; + } + + async getByUuidAny(uuid) { if (!uuid) return null; - const now = nowSeconds(); const cached = await this.#readCache(uuid); - if (cached) { - if (cached.revoked_at != null) return null; - if (isExpired(cached, now)) return null; - return cached; - } + if (cached) return cached; const rows = await this.clients.db.read( - 'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) LIMIT 1', - [uuid, now], + 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1', + [uuid], ); const normalized = this.#normalizeRow(rows[0]); if (!normalized) return null; - this.#writeCache(normalized).catch(() => { - // Best-effort backfill — local only. - }); + this.#writeCache(normalized).catch(() => {}); return normalized; }