From 503f899f04f0bb4b6379a4e540a522527d401328 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 8 Oct 2026 20:36:01 -0700 Subject: [PATCH] auth: app-issued access tokens older than their app stop authenticating An access token whose row or parent session predates the app now holding its uid is refused, the same check app and worker sessions already get. --- .../auth/AuthService.appDeletion.test.ts | 87 +++++++++++++++++++ src/backend/services/auth/AuthService.ts | 17 ++++ 2 files changed, 104 insertions(+) diff --git a/src/backend/services/auth/AuthService.appDeletion.test.ts b/src/backend/services/auth/AuthService.appDeletion.test.ts index 7937950f9..7ef61573f 100644 --- a/src/backend/services/auth/AuthService.appDeletion.test.ts +++ b/src/backend/services/auth/AuthService.appDeletion.test.ts @@ -138,6 +138,25 @@ const createApp = async () => { return { app, actor: makeActor({ user: owner! }) }; }; +/** A file in the user's AppData for `appUid`, and an app-minted read token. */ +const mintAppDataReadToken = async ( + appToken: string, + username: string, + appUid: string, +) => { + const userRow = await env.server.stores.user.getByUsername(username); + const entry = (await env.server.services.fs.touch(userRow!.id, { + path: `/${username}/AppData/${appUid}/${uuidv4()}.txt`, + createMissingParents: true, + } as never)) as { uuid: string }; + const res = await api('/auth/create-access-token', appToken, { + permissions: [`fs:${entry.uuid}:read`], + }); + expect(res.status, await res.clone().text()).toBe(200); + const { token } = (await res.json()) as { token: string }; + return { file: entry.uuid, token }; +}; + const authenticate = (token: string) => env.server.services.auth.authenticate(token) as Promise<{ actor?: unknown; @@ -213,6 +232,74 @@ describe('an app uid that returns after its app was deleted', () => { expect(getA.status).toBe(401); }); + it('stops an access token the deleted app minted from authenticating as the next app', async () => { + const devA = env.users.other; + const visitor = env.users.user; + const devB = await createTestUser(env.server, { + username: `devb${uuidv4().slice(0, 8)}`, + password: 'dev-b-password-123', + }); + + const sub = `shop-${uuidv4().slice(0, 8)}`; + const origin = `http://${sub}.site.puter.localhost`; + + const aCreate = await call(devA.token, 'puter-subdomains', 'create', { + object: { + subdomain: sub, + root_dir: await mkSiteDir(devA.username), + }, + }); + expect(aCreate.status, aCreate.text).toBe(200); + const { token: appTokenA, app_uid: uid } = await signIn( + visitor.token, + origin, + ); + const { file, token: scopedA } = await mintAppDataReadToken( + appTokenA, + visitor.username, + uid, + ); + + const delApp = await call(devA.token, 'puter-apps', 'delete', { uid }); + expect(delApp.status, delApp.text).toBe(200); + const delSite = await call(devA.token, 'puter-subdomains', 'delete', { + id: { subdomain: sub }, + }); + expect(delSite.status, delSite.text).toBe(200); + await backdateSession(sessionUid(appTokenA), 3600); + await backdateSession(sessionUid(scopedA), 3600); + + // The next developer's sign-in brings the uid back. + const bCreate = await call(devB.token, 'puter-subdomains', 'create', { + object: { + subdomain: sub, + root_dir: await mkSiteDir(devB.username), + }, + }); + expect(bCreate.status, bCreate.text).toBe(200); + expect((await signIn(devB.token, origin)).app_uid).toBe(uid); + + expect((await authenticate(scopedA)).reauth?.reason).toBe( + 'session_revoked', + ); + const stat = await api('/stat', scopedA, { uid: file }); + expect(stat.status).toBe(401); + }); + + it('keeps an access token its unchanged app minted', async () => { + const { app, actor } = await createApp(); + const appToken = await env.server.services.auth.getUserAppToken( + actor, + app.uid, + ); + const { token } = await mintAppDataReadToken( + appToken, + env.users.user.username, + app.uid, + ); + expect((await authenticate(token)).actor).toBeTruthy(); + }); + it("keeps an unchanged app's session and token", async () => { const { app, actor } = await createApp(); const first = await env.server.services.auth.getUserAppToken( diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index c5b2ba0cd..ec3a352a3 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -2427,6 +2427,12 @@ export class AuthService extends PuterService { return created - APP_SESSION_CLOCK_SKEW_SECONDS; } + #createdBefore(row: SessionRow | null, notBefore: number | null): boolean { + if (notBefore === null || !row) return false; + const createdAt = Number(row.created_at); + return createdAt > 0 && createdAt < notBefore; + } + async #actorFromAccessTokenToken( decoded: AccessTokenPayload, ctx: { ip?: string; userAgent?: string } = {}, @@ -2437,6 +2443,7 @@ export class AuthService extends PuterService { if (!user) return { invalid: true }; let session: SessionRow | null = null; + let parentSession: SessionRow | null = null; if (decoded.session_uid) { const rawRow = (await this.stores.session.getByUuidAny( decoded.session_uid, @@ -2468,6 +2475,7 @@ export class AuthService extends PuterService { ) { return { reauth: { reason: 'session_expired' } }; } + parentSession = parent; } session = rawRow; } @@ -2478,6 +2486,15 @@ export class AuthService extends PuterService { if (!app) return { invalid: true }; const blocked = await this.#appOriginBlock(app); if (blocked) return { blocked }; + // As with app sessions: a token minted under an earlier app that + // held this uid doesn't authenticate as the current one. + const notBefore = this.#appSessionsNotBefore(app); + if ( + this.#createdBefore(session, notBefore) || + this.#createdBefore(parentSession, notBefore) + ) { + return { reauth: { reason: 'session_revoked' } }; + } authorizer = this.#buildAppUnderUserActor(user, app, null); } else { authorizer = this.#buildUserActor(user, null);