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({