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);