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.
This commit is contained in:
Daniel Salazar committed 2026-10-08 20:41:15 -07:00
1 parent db26193a55
commit 503f899f04
2 files changed
+104

No files matched your search

@@ -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(
+17
View File
@@ -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);