feat: add verification for v2 auth

This commit is contained in:
Daniel Salazar
2026-05-25 19:03:18 -04:00
parent 3649109f40
commit fbdd5830eb
11 changed files with 885 additions and 145 deletions
+5 -27
View File
@@ -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. */
@@ -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<string, number>;
}> = [];
return {
calls,
store: {
incr: async (args: {
key: string;
pathAndAmountMap: Record<string, number>;
}) => {
calls.push(args);
return { res: {} as Record<string, number>, 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=<r> auth_id=<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
+45 -16
View File
@@ -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<SystemKVStore, 'incr'> | undefined,
pathAndAmountMap: Record<string, number>,
): 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<void> => {
// 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();
@@ -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 ────────────────────────────────────────────
+12 -7
View File
@@ -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',
+1
View File
@@ -471,6 +471,7 @@ export class PuterServer {
createAuthProbe({
authService,
cookieName: this.#config.cookie_name,
kvStore: this.stores.kv,
}),
);
}
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<string> => {
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();
+96 -70
View File
@@ -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<Actor | null> {
const result = await this.authenticate(token);
return result.actor ?? null;
}
async authenticate(token: string): Promise<AuthResult> {
let decoded: AnyTokenPayload;
try {
decoded = this.services.token.verify<AnyTokenPayload>(
@@ -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<Actor | null> {
): Promise<AuthResult> {
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<Actor | null> {
): Promise<AuthResult> {
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<Actor | null> {
if (!decoded.token_uid || !decoded.user_uid) return null;
): Promise<AuthResult> {
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,
},
},
};
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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',
);
});
});
+71 -11
View File
@@ -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<string, unknown> };
/**
* 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,
},
{},
+12 -11
View File
@@ -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;
}