mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 21:01:27 +00:00
feat: add verification for v2 auth
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
{},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user