From bc80c6eda27f42bfb6de59f7ac0e9a08f87cbe90 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sat, 10 Oct 2026 19:08:07 -0400 Subject: [PATCH] refactor(auth): one hosted-asset token API keyed by kind The private-asset and public hosted-actor cookies had separate create/ verify methods, four cookie-name getters and two identical options getters, differing only in the kind claim and what the host field was called. They are now createHostedAssetToken(kind, claims), verifyHostedAssetToken(kind, token, expected), getHostedAssetCookieOptions(), and the exported HOSTED_ASSET_COOKIES names. Token claims, cookie names and every error message are unchanged; puterSite and privateAppGate call the new methods. --- .../http/middleware/privateAppGate.test.ts | 21 +- .../core/http/middleware/privateAppGate.ts | 34 +-- .../core/http/middleware/puterSite.test.ts | 20 +- src/backend/core/http/middleware/puterSite.ts | 29 +- src/backend/services/auth/AuthService.test.ts | 142 ++++++---- src/backend/services/auth/AuthService.ts | 248 ++++++------------ src/backend/services/auth/TokenService.ts | 2 +- 7 files changed, 230 insertions(+), 266 deletions(-) diff --git a/src/backend/core/http/middleware/privateAppGate.test.ts b/src/backend/core/http/middleware/privateAppGate.test.ts index 248ad5b0e..d2cd757cc 100644 --- a/src/backend/core/http/middleware/privateAppGate.test.ts +++ b/src/backend/core/http/middleware/privateAppGate.test.ts @@ -21,7 +21,10 @@ import type { Request } from 'express'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import { makeActor } from '../../actor'; -import type { AuthService } from '../../../services/auth/AuthService'; +import { + HOSTED_ASSET_COOKIES, + type AuthService, +} from '../../../services/auth/AuthService'; import { PuterServer } from '../../../server'; import { setupTestServer } from '../../../testUtil'; import type { IConfig } from '../../../types'; @@ -795,16 +798,16 @@ describe('resolvePrivateIdentity', () => { it('returns the sticky private-cookie identity when the token matches the expected app/subdomain/host', async () => { const user = await makeUser(); const appUid = `app-${uuidv4()}`; - const token = await authService.createPrivateAssetToken({ + const token = await authService.createHostedAssetToken('private', { appUid, userUid: user.uuid, subdomain: 'beans', - privateHost: 'beans.app.puter.localhost', + host: 'beans.app.puter.localhost', }); const out = await resolvePrivateIdentity({ req: reqOf({ cookies: { - [authService.getPrivateAssetCookieName()]: token, + [HOSTED_ASSET_COOKIES.private.legacyName]: token, }, }), authService, @@ -820,16 +823,16 @@ describe('resolvePrivateIdentity', () => { it('falls through to req.actor when the private cookie is for a different app', async () => { const user = await makeUser(); - const wrongToken = await authService.createPrivateAssetToken({ + const wrongToken = await authService.createHostedAssetToken('private', { appUid: `app-${uuidv4()}`, userUid: user.uuid, subdomain: 'beans', - privateHost: 'beans.app.puter.localhost', + host: 'beans.app.puter.localhost', }); const out = await resolvePrivateIdentity({ req: reqOf({ cookies: { - [authService.getPrivateAssetCookieName()]: wrongToken, + [HOSTED_ASSET_COOKIES.private.legacyName]: wrongToken, }, actor: { user: { uuid: user.uuid }, @@ -1064,7 +1067,7 @@ describe('resolvePublicHostedIdentity', () => { it('returns the cookie identity when present and valid', async () => { const user = await makeUser(); const appUid = `app-${uuidv4()}`; - const token = await authService.createPublicHostedActorToken({ + const token = await authService.createHostedAssetToken('public', { appUid, userUid: user.uuid, subdomain: 'beans', @@ -1073,7 +1076,7 @@ describe('resolvePublicHostedIdentity', () => { const out = await resolvePublicHostedIdentity({ req: reqOf({ cookies: { - [authService.getPublicHostedActorCookieName()]: token, + [HOSTED_ASSET_COOKIES.public.legacyName]: token, }, }), authService, diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index 498876caf..6d04708ac 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -20,7 +20,10 @@ import type { AbstractDatabaseClient } from '@heyputer/backend/src/clients/database/DatabaseClient'; import type { Request } from 'express'; -import type { AuthService } from '../../../services/auth/AuthService'; +import { + HOSTED_ASSET_COOKIES, + type AuthService, +} from '../../../services/auth/AuthService'; import type { IConfig } from '../../../types'; import type { Actor } from '../../actor'; @@ -48,8 +51,7 @@ import type { Actor } from '../../actor'; * Sticky cookies (`puter.private.asset.token` for private apps, * `puter.public.hosted.actor.token` for public-hosted actors) are set after a * visitor passes the gate, and honored on subsequent requests to skip the full - * entitlement lookup. See AuthService `createPrivateAssetToken` / - * `createPublicHostedActorToken`. + * entitlement lookup. See AuthService `createHostedAssetToken`. */ export interface PrivateHostingConfig { @@ -356,8 +358,8 @@ export async function resolvePrivateIdentity(opts: { // back to the legacy dot-style name while the deprecation window // is open. A v1-signed token no longer verifies at all, so it lands // in the catch below and the chain re-mints under v2 on this response. - const v2CookieName = authService.getPrivateAssetCookieNameV2(); - const legacyCookieName = authService.getPrivateAssetCookieName(); + const { name: v2CookieName, legacyName: legacyCookieName } = + HOSTED_ASSET_COOKIES.private; const privateCookieToken = (typeof cookies?.[v2CookieName] === 'string' ? cookies[v2CookieName] @@ -367,12 +369,13 @@ export async function resolvePrivateIdentity(opts: { : null); if (privateCookieToken) { try { - const claims = await authService.verifyPrivateAssetToken( + const claims = await authService.verifyHostedAssetToken( + 'private', privateCookieToken, { - expectedAppUid, - expectedSubdomain, - expectedPrivateHost, + appUid: expectedAppUid, + subdomain: expectedSubdomain, + host: expectedPrivateHost, }, ); return { @@ -495,8 +498,8 @@ export async function resolvePublicHostedIdentity(opts: { const cookies = (req as Request & { cookies?: Record }) .cookies; - const publicCookieNameV2 = authService.getPublicHostedActorCookieNameV2(); - const publicCookieNameLegacy = authService.getPublicHostedActorCookieName(); + const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } = + HOSTED_ASSET_COOKIES.public; const publicCookieToken = (typeof cookies?.[publicCookieNameV2] === 'string' ? cookies[publicCookieNameV2] @@ -506,12 +509,13 @@ export async function resolvePublicHostedIdentity(opts: { : null); if (publicCookieToken) { try { - const claims = await authService.verifyPublicHostedActorToken( + const claims = await authService.verifyHostedAssetToken( + 'public', publicCookieToken, { - expectedAppUid, - expectedSubdomain, - expectedHost, + appUid: expectedAppUid, + subdomain: expectedSubdomain, + host: expectedHost, }, ); return { diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index e617b744a..da20f6418 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -22,6 +22,7 @@ import type { Request, Response } from 'express'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import { PuterServer } from '../../../server'; +import { HOSTED_ASSET_COOKIES } from '../../../services/auth/AuthService'; import { setupTestServer } from '../../../testUtil'; import type { IConfig } from '../../../types'; import type { Actor } from '../../actor'; @@ -1766,7 +1767,7 @@ describe('createPuterSiteMiddleware — private app sign-in token', () => { return out; }; - const cookieName = () => server.services.auth.getPrivateAssetCookieNameV2(); + const cookieName = () => HOSTED_ASSET_COOKIES.private.name; it('sets the cookie and redirects a top-level page to the same URL without the token', async () => { const out = await visit({ dest: 'document' }); @@ -1793,12 +1794,15 @@ describe('createPuterSiteMiddleware — private app sign-in token', () => { }); it('still strips the token when the visitor already holds the cookie', async () => { - const cookie = await server.services.auth.createPrivateAssetToken({ - appUid, - userUid: visitor.uuid, - subdomain: sub, - privateHost: host, - }); + const cookie = await server.services.auth.createHostedAssetToken( + 'private', + { + appUid, + userUid: visitor.uuid, + subdomain: sub, + host, + }, + ); const out = await visit({ dest: 'document', cookies: { [cookieName()]: cookie }, @@ -1809,7 +1813,7 @@ describe('createPuterSiteMiddleware — private app sign-in token', () => { it('serves without redirecting when the cookie could not be minted', async () => { // Without the cookie, the clean URL would bounce back to sign-in. const spy = vi - .spyOn(server.services.auth, 'createPrivateAssetToken') + .spyOn(server.services.auth, 'createHostedAssetToken') .mockRejectedValueOnce(new Error('mint failed')); try { const out = await visit({ dest: 'document' }); diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 9da8e22e1..9f5c1ea69 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -22,6 +22,7 @@ import { contentType as contentTypeFromMime } from 'mime-types'; import { posix as pathPosix } from 'node:path'; import type { puterClients } from '../../../clients'; import type { puterServices } from '../../../services'; +import { HOSTED_ASSET_COOKIES } from '../../../services/auth/AuthService'; import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts'; import type { puterStores } from '../../../stores'; import type { IConfig, LayerInstances } from '../../../types'; @@ -376,17 +377,20 @@ export const createPuterSiteMiddleware = ( if (!hasStickyCookie) { try { const token = - await layers.services.auth.createPrivateAssetToken({ - appUid: privateApp!.uid, - userUid: identity.userUid, - sessionUuid: identity.sessionUuid, - subdomain, - privateHost: host, - }); + await layers.services.auth.createHostedAssetToken( + 'private', + { + appUid: privateApp!.uid, + userUid: identity.userUid, + sessionUuid: identity.sessionUuid, + subdomain, + host, + }, + ); res.cookie( - layers.services.auth.getPrivateAssetCookieNameV2(), + HOSTED_ASSET_COOKIES.private.name, token, - layers.services.auth.getPrivateAssetCookieOptions({ + layers.services.auth.getHostedAssetCookieOptions({ requestHostname: host, }), ); @@ -458,7 +462,8 @@ export const createPuterSiteMiddleware = ( associatedApp?.uid ) { const token = - await layers.services.auth.createPublicHostedActorToken( + await layers.services.auth.createHostedAssetToken( + 'public', { appUid: associatedApp.uid, userUid: identity.userUid, @@ -468,9 +473,9 @@ export const createPuterSiteMiddleware = ( }, ); res.cookie( - layers.services.auth.getPublicHostedActorCookieNameV2(), + HOSTED_ASSET_COOKIES.public.name, token, - layers.services.auth.getPublicHostedActorCookieOptions({ + layers.services.auth.getHostedAssetCookieOptions({ requestHostname: host, }), ); diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 77f619c12..1e154bc0b 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -25,7 +25,7 @@ import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import { FULL_API_ACCESS } from '../permission/consts.js'; -import { AuthService } from './AuthService.js'; +import { AuthService, HOSTED_ASSET_COOKIES } from './AuthService.js'; function createAuthService(): AuthService { const [config, clients, stores, services] = [ @@ -2708,11 +2708,14 @@ describe('AuthService (integration)', () => { }); describe('private-asset / public hosted-actor tokens', () => { - it('private-asset cookie name and options shape', () => { - expect(authService.getPrivateAssetCookieName()).toBe( + it('cookie names and options shape', () => { + expect(HOSTED_ASSET_COOKIES.private.legacyName).toBe( 'puter.private.asset.token', ); - const opts = authService.getPrivateAssetCookieOptions({ + expect(HOSTED_ASSET_COOKIES.public.legacyName).toBe( + 'puter.public.hosted.actor.token', + ); + const opts = authService.getHostedAssetCookieOptions({ requestHostname: 'example.test', }); expect(opts.httpOnly).toBe(true); @@ -2721,27 +2724,25 @@ describe('AuthService (integration)', () => { expect(opts.hostname).toBe('example.test'); }); - it('public hosted-actor cookie name', () => { - expect(authService.getPublicHostedActorCookieName()).toBe( - 'puter.public.hosted.actor.token', - ); - }); - it('private-asset token round-trips and validates session binding', async () => { const user = await makeUser(); const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; const appUid = `app-${uuidv4()}`; - const token = await authService.createPrivateAssetToken({ + const token = await authService.createHostedAssetToken('private', { appUid, userUid: user.uuid, sessionUuid, subdomain: 'priv', }); - const decoded = await authService.verifyPrivateAssetToken(token, { - expectedAppUid: appUid, - expectedSubdomain: 'priv', - }); + const decoded = await authService.verifyHostedAssetToken( + 'private', + token, + { + appUid, + subdomain: 'priv', + }, + ); expect(decoded.userUid).toBe(user.uuid); expect(decoded.appUid).toBe(appUid); expect(decoded.subdomain).toBe('priv'); @@ -2752,49 +2753,50 @@ describe('AuthService (integration)', () => { expect(decoded.sessionUuid).not.toBe(sessionUuid); }); - it('verifyPrivateAssetToken throws 401 when expected app_uid mismatches', async () => { + it('verifying a private token throws 401 when expected app_uid mismatches', async () => { const user = await makeUser(); const appA = `app-${uuidv4()}`; const appB = `app-${uuidv4()}`; - const token = await authService.createPrivateAssetToken({ + const token = await authService.createHostedAssetToken('private', { appUid: appA, userUid: user.uuid, }); await expect( - authService.verifyPrivateAssetToken(token, { - expectedAppUid: appB, + authService.verifyHostedAssetToken('private', token, { + appUid: appB, }), ).rejects.toMatchObject({ statusCode: 401 }); }); - it('verifyPrivateAssetToken throws 401 when the bound session is gone', async () => { + it('verifying a private token throws 401 when the bound session is gone', async () => { const user = await makeUser(); const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; - const token = await authService.createPrivateAssetToken({ + const token = await authService.createHostedAssetToken('private', { appUid: `app-${uuidv4()}`, userUid: user.uuid, sessionUuid, }); await authService.revokeSession(sessionUuid); await expect( - authService.verifyPrivateAssetToken(token), + authService.verifyHostedAssetToken('private', token), ).rejects.toMatchObject({ statusCode: 401 }); }); it('public hosted-actor token round-trips and enforces expectations', async () => { const user = await makeUser(); const appUid = `app-${uuidv4()}`; - const token = await authService.createPublicHostedActorToken({ + const token = await authService.createHostedAssetToken('public', { appUid, userUid: user.uuid, host: 'host.example', }); - const decoded = await authService.verifyPublicHostedActorToken( + const decoded = await authService.verifyHostedAssetToken( + 'public', token, { - expectedAppUid: appUid, - expectedHost: 'host.example', + appUid, + host: 'host.example', }, ); expect(decoded.userUid).toBe(user.uuid); @@ -2802,24 +2804,27 @@ describe('AuthService (integration)', () => { expect(decoded.host).toBe('host.example'); }); - it('verifyPublicHostedActorToken rejects a private-kind token (kind mismatch)', async () => { + it('verifying as public rejects a private-kind token (kind mismatch)', async () => { const user = { uuid: uuidv4() }; - const privateToken = await authService.createPrivateAssetToken({ - appUid: `app-${uuidv4()}`, - userUid: user.uuid, - }); + const privateToken = await authService.createHostedAssetToken( + 'private', + { + appUid: `app-${uuidv4()}`, + userUid: user.uuid, + }, + ); await expect( - authService.verifyPublicHostedActorToken(privateToken), + authService.verifyHostedAssetToken('public', privateToken), ).rejects.toThrow(); }); // -- v2 hosted-asset migration -- it('v2 cookie names', () => { - expect(authService.getPrivateAssetCookieNameV2()).toBe( + expect(HOSTED_ASSET_COOKIES.private.name).toBe( 'puter_private_asset_token_v2', ); - expect(authService.getPublicHostedActorCookieNameV2()).toBe( + expect(HOSTED_ASSET_COOKIES.public.name).toBe( 'puter_public_hosted_actor_token_v2', ); }); @@ -2828,12 +2833,15 @@ describe('AuthService (integration)', () => { const user = await makeUser(); const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; - const token = await authService.createPrivateAssetToken({ + const token = await authService.createHostedAssetToken('private', { appUid: `app-${uuidv4()}`, userUid: user.uuid, sessionUuid, }); - const decoded = await authService.verifyPrivateAssetToken(token); + const decoded = await authService.verifyHostedAssetToken( + 'private', + token, + ); expect(decoded.authId).toBe(user.uuid); }); @@ -2841,22 +2849,24 @@ describe('AuthService (integration)', () => { const user = await makeUser(); const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; - const token = await authService.createPublicHostedActorToken({ + const token = await authService.createHostedAssetToken('public', { appUid: `app-${uuidv4()}`, userUid: user.uuid, sessionUuid, host: 'host.example', }); - const decoded = - await authService.verifyPublicHostedActorToken(token); + const decoded = await authService.verifyHostedAssetToken( + 'public', + token, + ); expect(decoded.authId).toBe(user.uuid); }); - it('verifyPublicHostedActorToken 401s when the bound session is revoked', async () => { + it('verifying a public token 401s when the bound session is revoked', async () => { const user = await makeUser(); const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; - const token = await authService.createPublicHostedActorToken({ + const token = await authService.createHostedAssetToken('public', { appUid: `app-${uuidv4()}`, userUid: user.uuid, sessionUuid, @@ -2864,7 +2874,7 @@ describe('AuthService (integration)', () => { }); await authService.revokeSession(sessionUuid); await expect( - authService.verifyPublicHostedActorToken(token), + authService.verifyHostedAssetToken('public', token), ).rejects.toMatchObject({ statusCode: 401 }); }); @@ -2872,20 +2882,60 @@ describe('AuthService (integration)', () => { const user = await makeUser(); const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; - const token = await authService.createPrivateAssetToken({ + const token = await authService.createHostedAssetToken('private', { appUid: `app-${uuidv4()}`, userUid: user.uuid, sessionUuid, }); // verify passes initially - await authService.verifyPrivateAssetToken(token); + await authService.verifyHostedAssetToken('private', token); // revokeCascade on the parent kills the asset row too await authService.revokeSession(sessionUuid); await expect( - authService.verifyPrivateAssetToken(token), + authService.verifyHostedAssetToken('private', token), ).rejects.toMatchObject({ statusCode: 401 }); }); + it.each([ + ['private', 'expectedPrivateHost', 'private-asset'], + ['public', 'expectedHost', 'public hosted-actor'], + ] as const)( + 'a %s token names its own host field and session in refusals', + async (kind, hostField, tokenName) => { + const user = await makeUser(); + const { session } = await authService.createSessionToken( + user, + {}, + ); + const sessionUuid = (session as { uuid: string }).uuid; + const token = await authService.createHostedAssetToken(kind, { + appUid: `app-${uuidv4()}`, + userUid: user.uuid, + sessionUuid, + host: 'a.example', + }); + + await expect( + authService.verifyHostedAssetToken(kind, token, { + host: 'b.example', + }), + ).rejects.toMatchObject({ + statusCode: 401, + message: `hosted-asset token ${hostField} mismatch`, + }); + await authService.revokeSession(sessionUuid); + await expect( + authService.verifyHostedAssetToken(kind, token, { + host: 'a.example', + }), + ).rejects.toMatchObject({ + statusCode: 401, + message: `${tokenName} token session no longer valid`, + legacyCode: 'session_required', + }); + }, + ); + it('a v1-signed hosted-asset cookie no longer verifies', async () => { // The gate treats this as a stale cookie and re-mints under v2, so // the only requirement here is that it does not verify. @@ -2906,7 +2956,7 @@ describe('AuthService (integration)', () => { 'dev-jwt-secret-change-me', ); await expect( - authService.verifyPrivateAssetToken(v1Token), + authService.verifyHostedAssetToken('private', v1Token), ).rejects.toThrow(); }); }); diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index ee4e42fa7..d829a5873 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -71,6 +71,29 @@ const SESSION_PURGE_PAUSE_MS = 50; const nowSeconds = (): number => Math.floor(Date.now() / 1000); +/** + * Hosted-asset cookie names by kind. `name` is what gets set; `legacyName` is + * still read through the v2 deprecation window. + */ +export const HOSTED_ASSET_COOKIES = { + private: { + name: 'puter_private_asset_token_v2', + legacyName: 'puter.private.asset.token', + }, + public: { + name: 'puter_public_hosted_actor_token_v2', + legacyName: 'puter.public.hosted.actor.token', + }, +} as const; + +export type HostedAssetKind = keyof typeof HOSTED_ASSET_COOKIES; + +/** How refusals name each kind; part of the existing error messages. */ +const HOSTED_ASSET_LABELS = { + private: { token: 'private-asset', expectedHost: 'expectedPrivateHost' }, + public: { token: 'public hosted-actor', expectedHost: 'expectedHost' }, +} as const; + export type ReauthReason = 'session_revoked' | 'session_expired'; /** @@ -1601,94 +1624,47 @@ export class AuthService extends PuterService { // -- Private / public hosted asset cookies ----------------------- // - // Ported from v1's `createPrivateAssetToken` / `createPublicHostedActor - // Token`. These are sticky cookies set by the puter-site middleware - // after a visitor successfully passes the private-app access gate - // (or is resolved as an actor on a public hosted app). Subsequent - // requests read the cookie and skip the full entitlement lookup. - // - // Claims are kept narrow — userUid + sessionUuid + appUid + subdomain - // + privateHost — so a cookie minted for one app/subdomain cannot be - // replayed against another. `verify*Token` enforces those expectations. + // Sticky cookies set by the puter-site middleware after a visitor passes + // the private-app access gate, or resolves as an actor on a public hosted + // app; later requests read the cookie and skip the entitlement lookup. + // Claims are kept narrow (user, session, app, subdomain, host) so a cookie + // minted for one app/subdomain cannot be replayed against another. - /** - * Cookie name that carries the sticky private-asset token. Legacy dot-style - * name kept readable through the v2 deprecation window — - * `resolvePrivateIdentity` still reads it as a fallback. - */ - getPrivateAssetCookieName(): string { - return 'puter.private.asset.token'; - } - - /** Cookie name that carries the public hosted-actor token (legacy). */ - getPublicHostedActorCookieName(): string { - return 'puter.public.hosted.actor.token'; - } - - /** V2 cookie name for the sticky private-asset token. */ - getPrivateAssetCookieNameV2(): string { - return 'puter_private_asset_token_v2'; - } - - /** V2 cookie name for the public hosted-actor token. */ - getPublicHostedActorCookieNameV2(): string { - return 'puter_public_hosted_actor_token_v2'; - } - - /** Shared cookie options for both sticky-auth cookies. */ - getPrivateAssetCookieOptions( + /** Cookie options for both kinds of hosted-asset cookie. */ + getHostedAssetCookieOptions( opts: { requestHostname?: string; } = {}, ): Record { - return this.#hostedAssetCookieOptions(opts.requestHostname); + // Scoped to the request host, no `domain`, so unrelated private-app + // subdomains don't share it. + const options: Record = { + httpOnly: true, + ...sessionCookieFlags(this.config), + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + path: '/', + }; + if (opts.requestHostname) { + // Browsers default to the response origin anyway; kept for logs. + options.hostname = opts.requestHostname; + } + return options; } - /** Alias — matching v1's naming. Same options used by both cookies. */ - getPublicHostedActorCookieOptions( - opts: { - requestHostname?: string; - } = {}, - ): Record { - return this.#hostedAssetCookieOptions(opts.requestHostname); - } - - async createPrivateAssetToken(claims: { - appUid: string; - userUid: string; - sessionUuid?: string; - subdomain?: string; - privateHost?: string; - }): Promise { + async createHostedAssetToken( + kind: HostedAssetKind, + claims: { + appUid: string; + userUid: string; + sessionUuid?: string; + subdomain?: string; + host?: string; + }, + ): Promise { const { assetSessionUuid, authId } = await this.#mintAssetSessionContext(claims.sessionUuid); return this.services.token.sign('hosted-asset', { - kind: 'private', - version: '2', - user_uid: claims.userUid, - app_uid: claims.appUid, - ...(assetSessionUuid - ? { session_uuid: assetSessionUuid } - : claims.sessionUuid - ? { session_uuid: claims.sessionUuid } - : {}), - ...(authId ? { auth_id: authId } : {}), - ...(claims.subdomain ? { subdomain: claims.subdomain } : {}), - ...(claims.privateHost ? { host: claims.privateHost } : {}), - }); - } - - async createPublicHostedActorToken(claims: { - appUid: string; - userUid: string; - sessionUuid?: string; - subdomain?: string; - host?: string; - }): Promise { - const { assetSessionUuid, authId } = - await this.#mintAssetSessionContext(claims.sessionUuid); - return this.services.token.sign('hosted-asset', { - kind: 'public', + kind, version: '2', user_uid: claims.userUid, app_uid: claims.appUid, @@ -1742,76 +1718,20 @@ export class AuthService extends PuterService { return { assetSessionUuid: row.uuid, authId }; } - async verifyPrivateAssetToken( + /** + * Verify a hosted-asset cookie of `kind` against what the request expects, + * and that the session it was minted under still exists and isn't revoked: + * `getByUuid` filters on `revoked_at IS NULL`, so a logout cascade + * invalidates every cookie minted under that web session. A cookie minted + * without a session (from an access-token actor) has nothing to bind. + */ + async verifyHostedAssetToken( + kind: HostedAssetKind, token: string, expected: { - expectedAppUid?: string; - expectedSubdomain?: string; - expectedPrivateHost?: string; - } = {}, - ): Promise<{ - userUid: string; - sessionUuid?: string; - appUid?: string; - subdomain?: string; - privateHost?: string; - authId?: string; - }> { - const decoded = this.#verifyHostedAssetToken(token, 'private'); - this.#assertExpected( - decoded, - 'app_uid', - expected.expectedAppUid, - 'expectedAppUid', - ); - this.#assertExpected( - decoded, - 'subdomain', - expected.expectedSubdomain, - 'expectedSubdomain', - ); - this.#assertExpected( - decoded, - 'host', - expected.expectedPrivateHost, - 'expectedPrivateHost', - ); - - // Bind the cookie to the user's session lifetime: the session row - // referenced at mint must still exist AND not be revoked. The - // `getByUuid` lookup is already filtered on `revoked_at IS NULL`, - // so a logout cascade transparently invalidates every asset - // cookie minted under that web session. Cookies minted without - // a session_uuid (e.g. from an access-token actor) skip the - // check; nothing to bind. - const sessionUuid = decoded.session_uuid as string | undefined; - if (sessionUuid) { - const session = await this.stores.session.getByUuid(sessionUuid); - if (!session) { - throw new HttpError( - 401, - 'private-asset token session no longer valid', - { legacyCode: 'session_required' }, - ); - } - } - - return { - userUid: decoded.user_uid as string, - sessionUuid, - appUid: decoded.app_uid as string | undefined, - subdomain: decoded.subdomain as string | undefined, - privateHost: decoded.host as string | undefined, - authId: decoded.auth_id as string | undefined, - }; - } - - async verifyPublicHostedActorToken( - token: string, - expected: { - expectedAppUid?: string; - expectedSubdomain?: string; - expectedHost?: string; + appUid?: string; + subdomain?: string; + host?: string; } = {}, ): Promise<{ userUid: string; @@ -1821,35 +1741,34 @@ export class AuthService extends PuterService { host?: string; authId?: string; }> { - const decoded = this.#verifyHostedAssetToken(token, 'public'); + const decoded = this.#verifyHostedAssetToken(token, kind); + const labels = HOSTED_ASSET_LABELS[kind]; this.#assertExpected( decoded, 'app_uid', - expected.expectedAppUid, + expected.appUid, 'expectedAppUid', ); this.#assertExpected( decoded, 'subdomain', - expected.expectedSubdomain, + expected.subdomain, 'expectedSubdomain', ); this.#assertExpected( decoded, 'host', - expected.expectedHost, - 'expectedHost', + expected.host, + labels.expectedHost, ); - // Same revocation cascade as the private path: if the cookie was - // minted under a now-revoked web session, drop it. const sessionUuid = decoded.session_uuid as string | undefined; if (sessionUuid) { const session = await this.stores.session.getByUuid(sessionUuid); if (!session) { throw new HttpError( 401, - 'public hosted-actor token session no longer valid', + `${labels.token} token session no longer valid`, { legacyCode: 'session_required' }, ); } @@ -1902,27 +1821,6 @@ export class AuthService extends PuterService { } } - #hostedAssetCookieOptions( - requestHostname?: string, - ): Record { - // Scope the cookie to the request host only. Not using `domain` - // so the browser doesn't share it across unrelated private-app - // subdomains — each app sees only its own cookie. - const options: Record = { - httpOnly: true, - ...sessionCookieFlags(this.config), - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days - path: '/', - }; - if (requestHostname) { - // Not strictly necessary (browsers default to the response - // origin when `domain` is absent), but included for clarity - // in server logs. - options.hostname = requestHostname; - } - return options; - } - // -- Access tokens ----------------------------------------------- /** diff --git a/src/backend/services/auth/TokenService.ts b/src/backend/services/auth/TokenService.ts index c1abfd90e..7e24094ff 100644 --- a/src/backend/services/auth/TokenService.ts +++ b/src/backend/services/auth/TokenService.ts @@ -158,7 +158,7 @@ const AUTH_COMPRESSION = def({ // `hosted-asset` scope signs the sticky cookies set after a visitor // passes the private/public-app access gate (see AuthService -// createPrivateAssetToken / createPublicHostedActorToken). Keeping it +// createHostedAssetToken). Keeping it // in its own scope prevents a cookie from ever being honored as a main // auth token. const HOSTED_ASSET_COMPRESSION = def({