fix: PUT-1429 (#3508)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-08-05 01:17:34 -07:00
committed by GitHub
parent 40c8d09f05
commit 6df292f495
4 changed files with 225 additions and 39 deletions
@@ -2340,6 +2340,131 @@ describe('AuthService (integration)', () => {
);
expect(after).toBeNull();
});
it('revokes a full-access token by raw token_uid, which has no grant rows to resolve against', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
[FULL_API_ACCESS],
]);
const decoded = server.services.token.verify('auth', jwt) as {
token_uid: string;
session_uid: string;
};
await authService.revokeAccessToken(actor, decoded.token_uid);
expect(
await server.stores.session.getByUuid(decoded.session_uid),
).toBeNull();
expect(await authService.authenticateFromToken(jwt)).toBeNull();
});
it('404s when another user names a full-access token by raw token_uid', async () => {
const owner = await makeUser();
const other = await makeUser();
const ownerActor = {
user: {
id: owner.id,
uuid: owner.uuid,
username: owner.username,
},
} as Actor;
const otherActor = {
user: {
id: other.id,
uuid: other.uuid,
username: other.username,
},
} as Actor;
const jwt = await authService.createAccessToken(ownerActor, [
[FULL_API_ACCESS],
]);
const decoded = server.services.token.verify('auth', jwt) as {
token_uid: string;
};
await expect(
authService.revokeAccessToken(otherActor, decoded.token_uid),
).rejects.toMatchObject({ statusCode: 404 });
expect(await authService.authenticateFromToken(jwt)).toBeTruthy();
});
});
describe('revokeSession on access-token rows', () => {
// The manage-sessions UI only ever holds the session uuid — the
// token itself is shown once at mint and never again — so revoking
// by uuid has to be enough to both kill the token and clear what it
// was allowed to do.
it('stops a full-access token authenticating when revoked by session uuid', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
[FULL_API_ACCESS],
]);
const decoded = server.services.token.verify('auth', jwt) as {
session_uid: string;
};
expect(await authService.authenticateFromToken(jwt)).toBeTruthy();
await authService.revokeSession(decoded.session_uid);
expect(await authService.authenticateFromToken(jwt)).toBeNull();
});
it('clears the grant manifest of a scoped token revoked by session uuid', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
[`user:${user.uuid}:email:read`],
]);
const decoded = server.services.token.verify('auth', jwt) as {
token_uid: string;
session_uid: string;
};
await authService.revokeSession(decoded.session_uid);
const rows = (await server.clients.db.read(
'SELECT 1 FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
[decoded.token_uid],
)) as unknown[];
expect(rows).toHaveLength(0);
expect(await authService.authenticateFromToken(jwt)).toBeNull();
});
it('clears grants of token rows parented to a revoked session', async () => {
const user = await makeUser();
const parent = await server.stores.session.create(user.id, {
kind: 'app',
});
const tokenUid = uuidv4();
await server.stores.session.create(user.id, {
kind: 'access_token',
parent_session_id: parent.uuid,
access_token_uid: tokenUid,
});
await server.clients.db.write(
'INSERT INTO `access_token_permissions` (`token_uid`, `authorizer_user_id`, `authorizer_app_id`, `permission`, `extra`) VALUES (?, ?, ?, ?, ?)',
[tokenUid, user.id, null, 'driver:test:call', '{}'],
);
await authService.revokeSession(parent.uuid);
const rows = (await server.clients.db.read(
'SELECT 1 FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
[tokenUid],
)) as unknown[];
expect(rows).toHaveLength(0);
});
});
describe('revokeAllSessions', () => {
+68 -24
View File
@@ -585,9 +585,21 @@ export class AuthService extends PuterService {
* Revoke a session by uuid, cascading to any rows whose `parent_session_id`
* points at it. Used by the manage-sessions UI and by
* `removeSessionByToken` — semantics are identical.
*
* This is the revoke path for _every_ session kind, access tokens included:
* the uuid is what `listSessions` hands the UI, and ownership is checked
* against the row itself by the caller. Their grants are dropped here so
* the two entry points leave the same state behind.
*/
async revokeSession(uuid: string): Promise<void> {
// Read first — after the cascade these rows carry `revoked_at` and
// no longer count as active.
const tokenUids =
await this.stores.session.accessTokenUidsForCascade(uuid);
await this.stores.session.revokeCascade(uuid);
for (const tokenUid of tokenUids) {
await this.#dropAccessTokenGrants(tokenUid);
}
}
/**
@@ -1545,7 +1557,13 @@ export class AuthService extends PuterService {
// A signature-verified JWT is itself proof of who issued the token —
// the body's `user_uid` was set by createAccessToken at mint time.
// For raw-uuid input we fall back to the persisted authorizer.
// For raw-uuid input the session row is the primary authority: a
// full-access token carries its grant as a signed claim and writes
// no `access_token_permissions` row to resolve against, so reading
// ownership from the manifest alone leaves the broadest token we
// issue unrevokable. The manifest stays as a fallback for rows that
// predate session-backed access tokens.
let sessionRow: SessionRow | null = null;
if (issuerUuidFromJwt !== undefined) {
if (issuerUuidFromJwt !== actor.user.uuid) {
throw new HttpError(404, 'Access token not found', {
@@ -1553,40 +1571,66 @@ export class AuthService extends PuterService {
});
}
} else {
const rows = (await this.clients.db.read(
'SELECT `authorizer_user_id` FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
[tokenUid],
)) as Array<{ authorizer_user_id?: number | null }>;
const ownerId = rows[0]?.authorizer_user_id ?? null;
if (ownerId === null || ownerId !== actor.user.id) {
sessionRow =
await this.stores.session.findActiveByAccessTokenUid(tokenUid);
const ownerId =
sessionRow?.user_id ??
(await this.#accessTokenAuthorizerId(tokenUid));
if (ownerId == null || ownerId !== actor.user.id) {
throw new HttpError(404, 'Access token not found', {
legacyCode: 'not_found',
});
}
}
// Permissions rows still DELETE — the "no DELETE on revoke"
// rule scoped to the `sessions` table (where the audit trail of
// when a session existed/was revoked is load-bearing for forensic
// queries and the cascade graph). `access_token_permissions`
// rows are the grant manifest for an *active* token; once its
// session is soft-revoked, the grants are dead-weight cache
// entries that would only confuse `checkMany`. If we later need
// permission-grant history for audit, that becomes a
// `revoked_at` column on this table, not a behavior change here.
await this.#dropAccessTokenGrants(tokenUid);
if (sessionUidFromJwt) {
await this.stores.session.removeByUuid(sessionUidFromJwt);
} else {
// A v1 JWT carries no `session_uid`, so the row still has to be
// found by token identity here.
const row =
sessionRow ??
(await this.stores.session.findActiveByAccessTokenUid(
tokenUid,
));
if (row) await this.stores.session.removeByUuid(row.uuid);
}
}
/**
* Persisted authorizer of an access token, from its grant manifest. Returns
* null for a token with no grants — which every full-access token is, so
* callers need another source of ownership before treating null as "not
* yours".
*/
async #accessTokenAuthorizerId(tokenUid: string): Promise<number | null> {
const rows = (await this.clients.db.read(
'SELECT `authorizer_user_id` FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
[tokenUid],
)) as Array<{ authorizer_user_id?: number | null }>;
return rows[0]?.authorizer_user_id ?? null;
}
/**
* Drop an access token's grant manifest.
*
* These rows DELETE rather than soft-revoke — the "no DELETE on revoke"
* rule is scoped to the `sessions` table, where the audit trail of when a
* session existed and when it died is load-bearing for forensic queries and
* the cascade graph. `access_token_permissions` rows are the grant manifest
* for an _active_ token; once its session is soft-revoked they are
* dead-weight cache entries that would only confuse `checkMany`. If we
* later need grant history for audit, that becomes a `revoked_at` column on
* this table, not a behavior change here.
*/
async #dropAccessTokenGrants(tokenUid: string): Promise<void> {
await this.clients.db.write(
'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?',
[tokenUid],
);
await this.stores.permission.invalidateAccessTokenPerms(tokenUid);
if (sessionUidFromJwt) {
await this.stores.session.removeByUuid(sessionUidFromJwt);
} else {
const row =
await this.stores.session.findActiveByAccessTokenUid(tokenUid);
if (row) await this.stores.session.removeByUuid(row.uuid);
}
}
// -- Internals ---------------------------------------------------
@@ -374,6 +374,30 @@ export class SessionStore extends PuterStore {
await this.publishCacheKeys({ keys, broadcast: true });
}
/**
* Access-token identities among the rows `revokeCascade(rootUuid)` would
* affect the root itself when it is a token row, plus any token row
* parented to it. Callers use these to drop grants that would otherwise
* outlive the revoked row. Must be read _before_ the cascade, while the
* rows still qualify as active.
*
* Both identity columns are consulted: v2 rows carry `access_token_uid`
* from mint, v1 rows get `legacy_token_uid` backfilled on first verify.
*/
async accessTokenUidsForCascade(rootUuid) {
if (!rootUuid) return [];
const rows = await this.clients.db.read(
"SELECT `access_token_uid`, `legacy_token_uid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `kind` = 'access_token' AND `revoked_at` IS NULL",
[rootUuid, rootUuid],
);
const uids = [];
for (const row of rows) {
const uid = row.access_token_uid ?? row.legacy_token_uid;
if (uid) uids.push(uid);
}
return uids;
}
/**
* Active session row whose access-token identity matches `tokenUid`. Covers
* both v2 (`access_token_uid` set at mint) and v1 lazy-backfill
+8 -15
View File
@@ -560,26 +560,19 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
const anti_csrf = await services.get('anti-csrf').token();
// Route access-token rows to the dedicated endpoint
// so `access_token_permissions` is cleared in addition
// to the session row being soft-revoked. Everything
// else (web/app/asset/worker) goes through cascade-
// capable /auth/revoke-session.
const isAccessToken = session.kind === 'access_token';
const url = isAccessToken
? `${window.api_origin}/auth/revoke-access-token`
: `${window.api_origin}/auth/revoke-session`;
const body = isAccessToken
? { tokenOrUuid: session.uuid, anti_csrf }
: { uuid: session.uuid, anti_csrf };
const resp = await fetch(url, {
// Every kind revokes by session uuid, access tokens
// included: the uuid is what list-sessions gave us, and
// it's the only identifier we hold — the token itself is
// shown once at mint and never again. /auth/revoke-session
// resolves ownership from the row and cascades, and
// clears the token's grants on the way through.
const resp = await fetch(`${window.api_origin}/auth/revoke-session`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
body: JSON.stringify({ uuid: session.uuid, anti_csrf }),
});
if ( resp.ok ) {
// Full reload — cascade may have killed children